diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index 8d065efcd..b57075bb4 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -1,11 +1,18 @@ package sts import ( + "fmt" "time" "github.com/golang-jwt/jwt/v5" + "github.com/seaweedfs/seaweedfs/weed/glog" ) +// 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 @@ -63,6 +70,17 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo { 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 + } + return &SessionInfo{ SessionId: c.SessionId, SessionName: c.SessionName, @@ -75,6 +93,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo { ExternalUserId: c.ExternalUserId, ProviderIssuer: c.ProviderIssuer, RequestContext: c.RequestContext, + Credentials: credentials, } } diff --git a/weed/iam/sts/session_claims_test.go b/weed/iam/sts/session_claims_test.go new file mode 100644 index 000000000..d7a1769bb --- /dev/null +++ b/weed/iam/sts/session_claims_test.go @@ -0,0 +1,282 @@ +package sts + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSTSSessionClaimsToSessionInfo tests the ToSessionInfo conversion +func TestSTSSessionClaimsToSessionInfo(t *testing.T) { + sessionId := "test-session-123" + issuer := "test-issuer" + expiresAt := time.Now().Add(time.Hour) + + claims := NewSTSSessionClaims(sessionId, issuer, expiresAt). + WithSessionName("test-session-name"). + WithRoleInfo( + "arn:aws:iam::123456789012:role/test-role", + "arn:aws:iam::123456789012:assumed-role/test-role/session", + "arn:aws:iam::123456789012:assumed-role/test-role/session", + ). + WithIdentityProvider("oidc", "user-123", "https://issuer.example.com"). + WithMaxDuration(time.Hour) + + sessionInfo := claims.ToSessionInfo() + + // Verify basic claims are converted + assert.Equal(t, sessionId, sessionInfo.SessionId) + assert.Equal(t, "test-session-name", sessionInfo.SessionName) + assert.Equal(t, "arn:aws:iam::123456789012:role/test-role", sessionInfo.RoleArn) + assert.Equal(t, "arn:aws:iam::123456789012:assumed-role/test-role/session", sessionInfo.AssumedRoleUser) + assert.Equal(t, "oidc", sessionInfo.IdentityProvider) + assert.Equal(t, "user-123", sessionInfo.ExternalUserId) + + // Verify credentials are generated + assert.NotNil(t, sessionInfo.Credentials, "credentials should be populated") + assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId, "access key should be generated") + assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey, "secret key should be generated") + // Credential expiration may have sub-second differences, so just check they're close + assert.True(t, sessionInfo.Credentials.Expiration.Sub(expiresAt) < time.Second, "credential expiration should match session expiration") + + // Verify expiration is preserved (within 1 second tolerance for timing differences) + assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second) +} + +// TestSTSSessionClaimsToSessionInfoCredentialGeneration tests that credentials are deterministically generated for access key and session token +func TestSTSSessionClaimsToSessionInfoCredentialGeneration(t *testing.T) { + sessionId := "deterministic-session-id" + issuer := "test-issuer" + expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) + + claims1 := NewSTSSessionClaims(sessionId, issuer, expiresAt) + sessionInfo1 := claims1.ToSessionInfo() + + // Create another claims object with the same session ID and expiration + claims2 := NewSTSSessionClaims(sessionId, issuer, expiresAt) + sessionInfo2 := claims2.ToSessionInfo() + + // Verify that both have valid credentials + assert.NotNil(t, sessionInfo1.Credentials, "credentials should be populated") + assert.NotNil(t, sessionInfo2.Credentials, "credentials should be populated") + + // Verify deterministic generation: same SessionId should produce identical access key ID + // (based on hash of session ID, not random) + assert.Equal(t, sessionInfo1.Credentials.AccessKeyId, sessionInfo2.Credentials.AccessKeyId, + "same session ID should produce identical access key ID (deterministic hash-based generation)") + + // Session token is also deterministic (hash-based on session ID) + assert.Equal(t, sessionInfo1.Credentials.SessionToken, sessionInfo2.Credentials.SessionToken, + "same session ID should produce identical session token (deterministic hash-based generation)") + + // Secret access key is NOW deterministic (hash-based on session ID, not random!) + // This is critical for signature verification: the same session ID must regenerate + // the same secret key so that signature verification succeeds. + assert.Equal(t, sessionInfo1.Credentials.SecretAccessKey, sessionInfo2.Credentials.SecretAccessKey, + "same session ID should produce identical secret access key (deterministic hash-based generation)") + + // Expiration should match + assert.WithinDuration(t, sessionInfo1.Credentials.Expiration, sessionInfo2.Credentials.Expiration, 1*time.Second, + "credentials expiration should match") +} + +// TestSTSSessionClaimsToSessionInfoPreservesAllFields tests that all fields are preserved +func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) { + sessionId := "test-session-id" + issuer := "test-issuer" + expiresAt := time.Now().Add(2 * time.Hour) + + policies := []string{"policy1", "policy2"} + requestContext := map[string]interface{}{ + "sourceIp": "192.168.1.1", + "userAgent": "test-agent", + } + + claims := NewSTSSessionClaims(sessionId, issuer, expiresAt). + WithSessionName("session-name"). + WithRoleInfo("role-arn", "assumed-role", "principal"). + WithIdentityProvider("provider", "external-id", "issuer"). + WithPolicies(policies). + WithRequestContext(requestContext). + WithMaxDuration(2 * time.Hour) + + sessionInfo := claims.ToSessionInfo() + + // Verify all fields are preserved + assert.Equal(t, sessionId, sessionInfo.SessionId) + assert.Equal(t, "session-name", sessionInfo.SessionName) + assert.Equal(t, "role-arn", sessionInfo.RoleArn) + assert.Equal(t, "assumed-role", sessionInfo.AssumedRoleUser) + assert.Equal(t, "principal", sessionInfo.Principal) + assert.Equal(t, "provider", sessionInfo.IdentityProvider) + assert.Equal(t, "external-id", sessionInfo.ExternalUserId) + assert.Equal(t, "issuer", sessionInfo.ProviderIssuer) + assert.Equal(t, policies, sessionInfo.Policies) + assert.Equal(t, requestContext, sessionInfo.RequestContext) + assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second) +} + +// TestSTSSessionClaimsToSessionInfoEmptyFields tests handling of empty/nil fields +func TestSTSSessionClaimsToSessionInfoEmptyFields(t *testing.T) { + sessionId := "minimal-session" + issuer := "issuer" + expiresAt := time.Now().Add(time.Hour) + + // Create claims with minimal fields + claims := NewSTSSessionClaims(sessionId, issuer, expiresAt) + + sessionInfo := claims.ToSessionInfo() + + // Verify basic fields are preserved + assert.Equal(t, sessionId, sessionInfo.SessionId) + assert.Empty(t, sessionInfo.SessionName) + assert.Empty(t, sessionInfo.RoleArn) + + // Verify credentials are still generated even with minimal fields + assert.NotNil(t, sessionInfo.Credentials) + assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId) + assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey) +} + +// TestSTSSessionClaimsToSessionInfoCredentialExpiration tests credential expiration +func TestSTSSessionClaimsToSessionInfoCredentialExpiration(t *testing.T) { + sessionId := "test-session" + issuer := "issuer" + + tests := []struct { + name string + expiresAt time.Time + expectNotExpired bool + description string + }{ + { + name: "future_expiration", + expiresAt: time.Now().Add(time.Hour), + expectNotExpired: true, + description: "Credentials should not be expired if ExpiresAt is in the future", + }, + { + name: "past_expiration", + expiresAt: time.Now().Add(-time.Hour), + expectNotExpired: false, + description: "Credentials should be expired if ExpiresAt is in the past", + }, + { + name: "near_future_expiration", + expiresAt: time.Now().Add(time.Minute), + expectNotExpired: true, + description: "Credentials should not be expired even if close to expiration", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + claims := NewSTSSessionClaims(sessionId, issuer, tc.expiresAt) + sessionInfo := claims.ToSessionInfo() + + assert.NotNil(t, sessionInfo.Credentials) + // Check expiration within 1 second due to timing precision (symmetric tolerance) + assert.WithinDuration(t, tc.expiresAt, sessionInfo.Credentials.Expiration, time.Second, + "credential expiration should be within 1 second of session expiration") + // We set tc.expiresAt to past/future values to exercise expiration handling. + // Assert the credentials' expiration relative to now to exercise code behavior + if tc.expectNotExpired { + assert.True(t, time.Now().Before(sessionInfo.Credentials.Expiration), tc.description) + } else { + assert.True(t, time.Now().After(sessionInfo.Credentials.Expiration), tc.description) + } + }) + } +} + +// TestSessionInfoIntegration tests the full integration of session info flow +func TestSessionInfoIntegration(t *testing.T) { + // Create a session claim + sessionId, err := GenerateSessionId() + require.NoError(t, err) + + expiresAt := time.Now().Add(time.Hour) + claims := NewSTSSessionClaims(sessionId, "test-issuer", expiresAt). + WithSessionName("integration-test"). + WithRoleInfo( + "arn:aws:iam::123456789012:role/integration", + "arn:aws:iam::123456789012:assumed-role/integration/test", + "arn:aws:iam::123456789012:assumed-role/integration/test", + ). + WithIdentityProvider("test-provider", "user-id", "https://test.example.com") + + // Convert to SessionInfo + sessionInfo := claims.ToSessionInfo() + + // Verify the session info has valid credentials + assert.NotNil(t, sessionInfo.Credentials) + assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId) + assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey) + + // Verify basic session properties + assert.Equal(t, sessionId, sessionInfo.SessionId) + assert.Equal(t, "integration-test", sessionInfo.SessionName) + assert.False(t, sessionInfo.ExpiresAt.IsZero()) + + // Verify that the session is valid + assert.True(t, sessionInfo.ExpiresAt.After(time.Now()), "session should not be expired") + assert.False(t, sessionInfo.Credentials.Expiration.Before(time.Now()), "credentials should not be expired") +} + +// TestSecretAccessKeyDeterminism verifies that secret access keys are deterministically +// generated from the session ID. This is CRITICAL for STS signature verification: +// The client generates a secret key and signs the request. When the server receives +// the request, it must regenerate the exact same secret key from the JWT claims +// to verify the signature. If the secret key is random, verification will always fail. +func TestSecretAccessKeyDeterminism(t *testing.T) { + sessionId := "critical-determinism-test" + expiration := time.Now().Add(time.Hour) + + // Generate credentials multiple times with the same session ID + credGen := NewCredentialGenerator() + + cred1, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) + assert.NoError(t, err) + assert.NotNil(t, cred1) + + cred2, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) + assert.NoError(t, err) + assert.NotNil(t, cred2) + + cred3, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) + assert.NoError(t, err) + assert.NotNil(t, cred3) + + // All three should have IDENTICAL secret access keys + assert.Equal(t, cred1.SecretAccessKey, cred2.SecretAccessKey, + "same sessionId must produce identical secret key on first and second call") + assert.Equal(t, cred2.SecretAccessKey, cred3.SecretAccessKey, + "same sessionId must produce identical secret key on second and third call") + + // All three should have IDENTICAL access key IDs + assert.Equal(t, cred1.AccessKeyId, cred2.AccessKeyId, + "same sessionId must produce identical access key ID") + assert.Equal(t, cred2.AccessKeyId, cred3.AccessKeyId, + "same sessionId must produce identical access key ID") + + // All three should have IDENTICAL session tokens + assert.Equal(t, cred1.SessionToken, cred2.SessionToken, + "same sessionId must produce identical session token") + assert.Equal(t, cred2.SessionToken, cred3.SessionToken, + "same sessionId must produce identical session token") + + // Different session IDs should produce different secrets + otherSessionId := "different-session" + credOther, err := credGen.GenerateTemporaryCredentials(otherSessionId, expiration) + assert.NoError(t, err) + assert.NotNil(t, credOther) + + assert.NotEqual(t, cred1.SecretAccessKey, credOther.SecretAccessKey, + "different session IDs must produce different secret keys") + assert.NotEqual(t, cred1.AccessKeyId, credOther.AccessKeyId, + "different session IDs must produce different access key IDs") + assert.NotEqual(t, cred1.SessionToken, credOther.SessionToken, + "different session IDs must produce different session tokens") +} diff --git a/weed/iam/sts/token_utils.go b/weed/iam/sts/token_utils.go index 3091ac519..6ba7196e4 100644 --- a/weed/iam/sts/token_utils.go +++ b/weed/iam/sts/token_utils.go @@ -149,7 +149,7 @@ func (c *CredentialGenerator) GenerateTemporaryCredentials(sessionId string, exp return nil, fmt.Errorf("failed to generate access key ID: %w", err) } - secretAccessKey, err := c.generateSecretAccessKey() + secretAccessKey, err := c.generateSecretAccessKey(sessionId) if err != nil { return nil, fmt.Errorf("failed to generate secret access key: %w", err) } @@ -174,16 +174,16 @@ func (c *CredentialGenerator) generateAccessKeyId(sessionId string) (string, err return "AKIA" + hex.EncodeToString(hash[:8]), nil // AWS format: AKIA + 16 chars } -// generateSecretAccessKey generates a random secret access key -func (c *CredentialGenerator) generateSecretAccessKey() (string, error) { - // Generate 32 random bytes for secret key - secretBytes := make([]byte, 32) - _, err := rand.Read(secretBytes) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(secretBytes), nil +// 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 +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 } // generateSessionTokenId generates a session token identifier diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 0cbed72a2..f15d2cd19 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -951,32 +951,75 @@ func (iam *IdentityAccessManagement) authenticateJWTWithIAM(r *http.Request) (*I return identity, s3err.ErrNone } +// IAM authorization path type constants +// iamAuthPath represents the type of IAM authorization path +type iamAuthPath string + +// IAM authorization path constants +const ( + iamAuthPathJWT iamAuthPath = "jwt" + iamAuthPathSTS_V4 iamAuthPath = "sts_v4" + iamAuthPathStatic_V4 iamAuthPath = "static_v4" + iamAuthPathNone iamAuthPath = "none" +) + +// determineIAMAuthPath determines the IAM authorization path based on available tokens and principals +func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthPath { + if sessionToken != "" && principal != "" { + return iamAuthPathJWT + } else if sessionToken != "" && principalArn != "" { + return iamAuthPathSTS_V4 + } else if principalArn != "" { + return iamAuthPathStatic_V4 + } + return iamAuthPathNone +} + // authorizeWithIAM authorizes requests using the IAM integration policy engine func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity *Identity, action Action, bucket string, object string) s3err.ErrorCode { ctx := r.Context() - // Get session info from request headers (for JWT-based authentication) + // Get session info from request headers + // First check for JWT-based authentication headers (X-SeaweedFS-Session-Token) sessionToken := r.Header.Get("X-SeaweedFS-Session-Token") principal := r.Header.Get("X-SeaweedFS-Principal") + // Fallback to AWS Signature V4 STS token if JWT token not present + // This handles the case where STS AssumeRoleWithWebIdentity generates temporary credentials + // that include an X-Amz-Security-Token header (in addition to the access key and secret) + if sessionToken == "" { + sessionToken = r.Header.Get("X-Amz-Security-Token") + if sessionToken == "" { + // Also check query parameters for presigned URLs with STS tokens + sessionToken = r.URL.Query().Get("X-Amz-Security-Token") + } + } + // Create IAMIdentity for authorization iamIdentity := &IAMIdentity{ Name: identity.Name, Account: identity.Account, } - // Handle both session-based (JWT) and static-key-based (V4 signature) principals - if sessionToken != "" && principal != "" { + // Determine authorization path and configure identity + authPath := determineIAMAuthPath(sessionToken, principal, identity.PrincipalArn) + switch authPath { + case iamAuthPathJWT: // JWT-based authentication - use session token and principal from headers iamIdentity.Principal = principal iamIdentity.SessionToken = sessionToken glog.V(3).Infof("Using JWT-based IAM authorization for principal: %s", principal) - } else if identity.PrincipalArn != "" { - // V4 signature authentication - use principal ARN from identity + case iamAuthPathSTS_V4: + // STS V4 signature authentication - use session token (from X-Amz-Security-Token) with principal ARN iamIdentity.Principal = identity.PrincipalArn - iamIdentity.SessionToken = "" // No session token for static credentials - glog.V(3).Infof("Using V4 signature IAM authorization for principal: %s", identity.PrincipalArn) - } else { + iamIdentity.SessionToken = sessionToken + glog.V(3).Infof("Using STS V4 signature IAM authorization for principal: %s with session token", identity.PrincipalArn) + case iamAuthPathStatic_V4: + // Static V4 signature authentication - use principal ARN without session token + iamIdentity.Principal = identity.PrincipalArn + iamIdentity.SessionToken = "" + glog.V(3).Infof("Using static V4 signature IAM authorization for principal: %s", identity.PrincipalArn) + default: glog.V(3).Info("No valid principal information for IAM authorization") return s3err.ErrAccessDenied } diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 13cd26b71..3a54f037b 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -205,32 +205,40 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh return nil, nil, "", nil, errCode } - // 2. Lookup user and credentials - identity, cred, found := iam.lookupByAccessKey(authInfo.AccessKey) - if !found { - // Log detailed error information for InvalidAccessKeyId - iam.m.RLock() - availableKeys := make([]string, 0, len(iam.accessKeyIdent)) - for key := range iam.accessKeyIdent { - availableKeys = append(availableKeys, key) - } - iam.m.RUnlock() - - glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v", - authInfo.AccessKey, len(availableKeys), iam.isAuthEnabled) + var cred *Credential - if glog.V(2) && len(availableKeys) > 0 { - glog.V(2).Infof("Available access keys: %v", availableKeys) - } - - return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID + // 2. Check for STS session token + sessionToken := r.Header.Get("X-Amz-Security-Token") + if sessionToken == "" { + sessionToken = r.URL.Query().Get("X-Amz-Security-Token") } + if sessionToken != "" { + // Validate STS session token + identity, cred, errCode = iam.validateSTSSessionToken(r, sessionToken, authInfo.AccessKey) + if errCode != s3err.ErrNone { + return nil, nil, "", nil, errCode + } + } else { + // 3. Lookup user and credentials + var found bool + identity, cred, found = iam.lookupByAccessKey(authInfo.AccessKey) + if !found { + // Log detailed error information for InvalidAccessKeyId (avoid slice allocation for performance) + iam.m.RLock() + keyCount := len(iam.accessKeyIdent) + iam.m.RUnlock() + + glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v", + authInfo.AccessKey, keyCount, iam.isAuthEnabled) + return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID + } - // Check service account expiration - if cred.isCredentialExpired() { - glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", - authInfo.AccessKey, cred.Expiration, time.Now().Unix()) - return nil, nil, "", nil, s3err.ErrAccessDenied + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + authInfo.AccessKey, cred.Expiration, time.Now().Unix()) + return nil, nil, "", nil, s3err.ErrAccessDenied + } } // 3. Perform permission check @@ -291,6 +299,93 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh return identity, cred, calculatedSignature, authInfo, s3err.ErrNone } +// validateSTSSessionToken validates an STS session token and extracts temporary credentials +func (iam *IdentityAccessManagement) validateSTSSessionToken(r *http.Request, sessionToken string, accessKey string) (*Identity, *Credential, s3err.ErrorCode) { + // Check if IAM integration with STS is available + if iam.iamIntegration == nil || iam.iamIntegration.stsService == nil { + glog.V(2).Infof("STS service not available, cannot validate session token") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Validate the session token with the STS service + ctx := r.Context() + sessionInfo, err := iam.iamIntegration.stsService.ValidateSessionToken(ctx, sessionToken) + if err != nil { + glog.V(2).Infof("Failed to validate STS session token: %v", err) + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Check if sessionInfo is nil + if sessionInfo == nil { + glog.Warningf("STS service returned nil session info for token validation") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Check if Credentials are nil + if sessionInfo.Credentials == nil { + glog.Warningf("STS service returned nil credentials in session info") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Validate that credentials have the required access key + if sessionInfo.Credentials.AccessKeyId == "" { + glog.Warningf("STS service returned empty AccessKeyId in credentials") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Verify that the access key in the request matches the one in the session token + if sessionInfo.Credentials.AccessKeyId != accessKey { + glog.V(2).Infof("Access key mismatch: request has %s, session token has %s", + accessKey, sessionInfo.Credentials.AccessKeyId) + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Check if the session has expired + if sessionInfo.ExpiresAt.IsZero() { + glog.Warningf("STS service returned zero/empty expiration time") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + if time.Now().After(sessionInfo.ExpiresAt) { + glog.V(2).Infof("STS session has expired at %v", sessionInfo.ExpiresAt) + return nil, nil, s3err.ErrExpiredToken + } + + // Validate required credential fields + if sessionInfo.Credentials.SecretAccessKey == "" { + glog.Warningf("STS service returned empty SecretAccessKey in credentials") + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Validate principal information + if sessionInfo.AssumedRoleUser == "" || sessionInfo.Principal == "" { + glog.Warningf("STS service returned empty AssumedRoleUser or Principal (user=%q, principal=%q)", + sessionInfo.AssumedRoleUser, sessionInfo.Principal) + return nil, nil, s3err.ErrInvalidAccessKeyID + } + + // Create a credential from the session info + cred := &Credential{ + AccessKey: sessionInfo.Credentials.AccessKeyId, + SecretKey: sessionInfo.Credentials.SecretAccessKey, + Status: "Active", + Expiration: sessionInfo.ExpiresAt.Unix(), + } + + // Create an identity for the STS session + // The identity represents the assumed role user + identity := &Identity{ + Name: sessionInfo.AssumedRoleUser, // Use the assumed role user as the identity name + Account: &AccountAdmin, // STS sessions use admin account + Credentials: []*Credential{cred}, + PrincipalArn: sessionInfo.Principal, + } + + glog.V(2).Infof("Successfully validated STS session token for principal: %s, assumed role user: %s", + sessionInfo.Principal, sessionInfo.AssumedRoleUser) + return identity, cred, s3err.ErrNone +} + // calculateAndVerifySignature contains the core logic for creating the canonical request, // string-to-sign, and comparing the final signature. func calculateAndVerifySignature(secretKey, method, urlPath, queryStr string, extractedSignedHeaders http.Header, authInfo *v4AuthInfo) (string, s3err.ErrorCode) { diff --git a/weed/s3api/auth_sts_v4_test.go b/weed/s3api/auth_sts_v4_test.go new file mode 100644 index 000000000..792221305 --- /dev/null +++ b/weed/s3api/auth_sts_v4_test.go @@ -0,0 +1,149 @@ +package s3api + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// TestAuthorizeWithIAMSessionTokenExtraction tests that the authorizeWithIAM function +// correctly extracts session tokens from multiple sources and prioritizes them appropriately. +// This is a regression test for the bug where X-Amz-Security-Token was not being checked +// for V4 signature authentication with STS credentials. +func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) { + t.Run("Extracts X-SeaweedFS-Session-Token from JWT auth", func(t *testing.T) { + req := &http.Request{ + Header: http.Header{ + "X-Seaweedfs-Session-Token": {"jwt-token-123"}, + "X-Seaweedfs-Principal": {"arn:aws:iam::user/test"}, + }, + URL: &url.URL{}, + } + + // Extract tokens the same way authorizeWithIAM does + sessionToken := req.Header.Get("X-SeaweedFS-Session-Token") + principal := req.Header.Get("X-SeaweedFS-Principal") + + assert.Equal(t, "jwt-token-123", sessionToken, "Should extract JWT session token from header") + assert.Equal(t, "arn:aws:iam::user/test", principal, "Should extract principal from header") + }) + + t.Run("Extracts X-Amz-Security-Token from V4 STS auth header", func(t *testing.T) { + req := &http.Request{ + Header: http.Header{ + "X-Amz-Security-Token": {"sts-token-header-456"}, + }, + URL: &url.URL{}, + } + + // Extract tokens the same way authorizeWithIAM does + sessionToken := req.Header.Get("X-SeaweedFS-Session-Token") + principal := req.Header.Get("X-SeaweedFS-Principal") + + // If JWT token is empty, should fallback to X-Amz-Security-Token + if sessionToken == "" { + sessionToken = req.Header.Get("X-Amz-Security-Token") + } + + assert.Equal(t, "sts-token-header-456", sessionToken, "Should fallback to X-Amz-Security-Token when JWT token is empty") + assert.Empty(t, principal, "JWT principal should be empty for V4 auth") + }) + + t.Run("Extracts X-Amz-Security-Token from query parameter (presigned URL)", func(t *testing.T) { + req := &http.Request{ + Header: http.Header{}, + URL: &url.URL{RawQuery: "X-Amz-Security-Token=sts-token-query-789"}, + } + + // Extract tokens the same way authorizeWithIAM does + sessionToken := req.Header.Get("X-SeaweedFS-Session-Token") + if sessionToken == "" { + sessionToken = req.Header.Get("X-Amz-Security-Token") + if sessionToken == "" { + sessionToken = req.URL.Query().Get("X-Amz-Security-Token") + } + } + + assert.Equal(t, "sts-token-query-789", sessionToken, "Should extract token from query parameter") + }) + + t.Run("JWT token takes precedence over X-Amz-Security-Token", func(t *testing.T) { + req := &http.Request{ + Header: http.Header{ + "X-Seaweedfs-Session-Token": {"jwt-preferred"}, + "X-Seaweedfs-Principal": {"arn:aws:iam::user/jwt-user"}, + "X-Amz-Security-Token": {"sts-fallback"}, + }, + URL: &url.URL{}, + } + + // Extract tokens the same way authorizeWithIAM does + sessionToken := req.Header.Get("X-SeaweedFS-Session-Token") + if sessionToken == "" { + sessionToken = req.Header.Get("X-Amz-Security-Token") + } + + assert.Equal(t, "jwt-preferred", sessionToken, "JWT token should take precedence") + }) +} + +// TestSTSSessionTokenIntoCredentials verifies that STS session tokens are properly +// preserved when converting to credentials for authorization. +func TestSTSSessionTokenIntoCredentials(t *testing.T) { + // Create a credential generator and session claims + credGen := sts.NewCredentialGenerator() + sessionId := "test-session-123" + expiresAt := time.Now().Add(time.Hour) + + // Generate temporary credentials + creds, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt) + require.NoError(t, err, "Should generate credentials successfully") + require.NotNil(t, creds, "Credentials should not be nil") + + // Verify all credential fields are present + assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should be present") + assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should be present") + assert.NotEmpty(t, creds.SessionToken, "SessionToken should be present for STS") + + // Verify deterministic generation (same session ID produces same credentials) + creds2, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt) + require.NoError(t, err) + + assert.Equal(t, creds.AccessKeyId, creds2.AccessKeyId, "AccessKeyId should be deterministic") + assert.Equal(t, creds.SecretAccessKey, creds2.SecretAccessKey, "SecretAccessKey should be deterministic") + assert.Equal(t, creds.SessionToken, creds2.SessionToken, "SessionToken should be deterministic for same sessionId") + + // Verify different session produces different credentials + creds3, err := credGen.GenerateTemporaryCredentials("different-session", expiresAt) + require.NoError(t, err) + + assert.NotEqual(t, creds.AccessKeyId, creds3.AccessKeyId, "Different sessions should produce different access key IDs") + assert.NotEqual(t, creds.SecretAccessKey, creds3.SecretAccessKey, "Different sessions should produce different secret keys") + assert.NotEqual(t, creds.SessionToken, creds3.SessionToken, "Different sessions should produce different session tokens") +} + +// TestActionConstantsForV4Auth verifies that action constants are properly available +// for use in authorization checks with V4 signature authentication. +func TestActionConstantsForV4Auth(t *testing.T) { + // Verify that S3 action constants are available + actions := map[string]string{ + "READ": s3_constants.ACTION_READ, + "WRITE": s3_constants.ACTION_WRITE, + "READ_ACP": s3_constants.ACTION_READ_ACP, + "WRITE_ACP": s3_constants.ACTION_WRITE_ACP, + "LIST": s3_constants.ACTION_LIST, + "TAGGING": s3_constants.ACTION_TAGGING, + "ADMIN": s3_constants.ACTION_ADMIN, + } + + for name, action := range actions { + assert.NotEmpty(t, action, "Action %s should not be empty", name) + } +} diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index 189c6ba86..a23ff2aca 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -95,6 +95,7 @@ const ( ErrInvalidQueryParams ErrInvalidQuerySignatureAlgo ErrExpiredPresignRequest + ErrExpiredToken ErrMalformedExpires ErrNegativeExpires ErrMaximumExpires @@ -405,6 +406,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "Request has expired", HTTPStatusCode: http.StatusForbidden, }, + ErrExpiredToken: { + Code: "ExpiredToken", + Description: "The provided token has expired.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrMalformedExpires: { Code: "AuthorizationQueryParametersError", Description: "X-Amz-Expires should be a number",