mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-01 20:26:27 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0078d8a82d | ||
|
|
e2b58a0a5b | ||
|
|
fdb1d96a2c | ||
|
|
5331e21f49 | ||
|
|
2c428f6a0a | ||
|
|
fa8830c71d | ||
|
|
9dd87aa80a | ||
|
|
00c37387db | ||
|
|
3d63c5cf10 | ||
|
|
1dc94e92f6 | ||
|
|
8001c3747d | ||
|
|
643558fcc9 | ||
|
|
68505d3fd3 | ||
|
|
ac824d4094 | ||
|
|
7472b9ce75 | ||
|
|
d405a5df35 | ||
|
|
371b311493 | ||
|
|
77afd24e65 | ||
|
|
974ddfe681 | ||
|
|
6382eb805a | ||
|
|
78a169197e | ||
|
|
d6f60b3f64 | ||
|
|
060d3310ca | ||
|
|
571338a474 | ||
|
|
198ecdb141 | ||
|
|
1223c5993e | ||
|
|
dd218ad4fc | ||
|
|
162557c72f |
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -699,13 +699,8 @@ func (s *STSService) validateRoleAssumptionForWebIdentity(ctx context.Context, r
|
||||
return fmt.Errorf("web identity token cannot be empty")
|
||||
}
|
||||
|
||||
// Basic role ARN format validation
|
||||
expectedPrefix := "arn:aws:iam::role/"
|
||||
if len(roleArn) < len(expectedPrefix) || roleArn[:len(expectedPrefix)] != expectedPrefix {
|
||||
return fmt.Errorf("invalid role ARN format: got %s, expected format: %s*", roleArn, expectedPrefix)
|
||||
}
|
||||
|
||||
// Extract role name and validate ARN format
|
||||
// Validate role ARN and extract role name
|
||||
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("invalid role ARN format: %s", roleArn)
|
||||
@@ -736,13 +731,8 @@ func (s *STSService) validateRoleAssumptionForCredentials(ctx context.Context, r
|
||||
return fmt.Errorf("identity cannot be nil")
|
||||
}
|
||||
|
||||
// Basic role ARN format validation
|
||||
expectedPrefix := "arn:aws:iam::role/"
|
||||
if len(roleArn) < len(expectedPrefix) || roleArn[:len(expectedPrefix)] != expectedPrefix {
|
||||
return fmt.Errorf("invalid role ARN format: got %s, expected format: %s*", roleArn, expectedPrefix)
|
||||
}
|
||||
|
||||
// Extract role name and validate ARN format
|
||||
// Validate role ARN and extract role name
|
||||
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("invalid role ARN format: %s", roleArn)
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
+25
-23
@@ -3,37 +3,39 @@ package utils
|
||||
import "strings"
|
||||
|
||||
// ExtractRoleNameFromPrincipal extracts role name from principal ARN
|
||||
// Handles both STS assumed role and IAM role formats
|
||||
// Handles both STS assumed role and IAM role formats with or without account ID:
|
||||
// - arn:aws:sts::assumed-role/Role/Session (legacy)
|
||||
// - arn:aws:sts::ACCOUNT:assumed-role/Role/Session (standard)
|
||||
// - arn:aws:iam::role/Role (legacy)
|
||||
// - arn:aws:iam::ACCOUNT:role/Role (standard)
|
||||
func ExtractRoleNameFromPrincipal(principal string) string {
|
||||
// Handle STS assumed role format: arn:aws:sts::assumed-role/RoleName/SessionName
|
||||
stsPrefix := "arn:aws:sts::assumed-role/"
|
||||
if strings.HasPrefix(principal, stsPrefix) {
|
||||
remainder := principal[len(stsPrefix):]
|
||||
// Split on first '/' to get role name
|
||||
if slashIndex := strings.Index(remainder, "/"); slashIndex != -1 {
|
||||
return remainder[:slashIndex]
|
||||
// Handle STS assumed role format
|
||||
if strings.HasPrefix(principal, "arn:aws:sts::") {
|
||||
remainder := principal[len("arn:aws:sts::"):]
|
||||
if idx := strings.Index(remainder, "assumed-role/"); idx != -1 {
|
||||
afterMarker := remainder[idx+len("assumed-role/"):]
|
||||
if slash := strings.Index(afterMarker, "/"); slash != -1 {
|
||||
return afterMarker[:slash]
|
||||
}
|
||||
return afterMarker
|
||||
}
|
||||
// If no slash found, return the remainder (edge case)
|
||||
return remainder
|
||||
}
|
||||
|
||||
// Handle IAM role format: arn:aws:iam::role/RoleName
|
||||
iamPrefix := "arn:aws:iam::role/"
|
||||
if strings.HasPrefix(principal, iamPrefix) {
|
||||
return principal[len(iamPrefix):]
|
||||
}
|
||||
|
||||
// Return empty string to signal invalid ARN format
|
||||
// This allows callers to handle the error explicitly instead of masking it
|
||||
return ""
|
||||
// Handle IAM role format
|
||||
return ExtractRoleNameFromArn(principal)
|
||||
}
|
||||
|
||||
// ExtractRoleNameFromArn extracts role name from an IAM role ARN
|
||||
// Specifically handles: arn:aws:iam::role/RoleName
|
||||
// Handles both formats:
|
||||
// - arn:aws:iam::role/RoleName (legacy, without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:role/RoleName (standard AWS format)
|
||||
func ExtractRoleNameFromArn(roleArn string) string {
|
||||
prefix := "arn:aws:iam::role/"
|
||||
if strings.HasPrefix(roleArn, prefix) && len(roleArn) > len(prefix) {
|
||||
return roleArn[len(prefix):]
|
||||
if !strings.HasPrefix(roleArn, "arn:aws:iam::") {
|
||||
return ""
|
||||
}
|
||||
remainder := roleArn[len("arn:aws:iam::"):]
|
||||
if idx := strings.Index(remainder, "role/"); idx != -1 {
|
||||
return remainder[idx+len("role/"):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractRoleNameFromArn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected string
|
||||
}{
|
||||
// Legacy format (without account ID)
|
||||
{
|
||||
name: "legacy_format_simple_role_name",
|
||||
roleArn: "arn:aws:iam::role/default",
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_custom_role_name",
|
||||
roleArn: "arn:aws:iam::role/MyRole",
|
||||
expected: "MyRole",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_with_path",
|
||||
roleArn: "arn:aws:iam::role/Path/MyRole",
|
||||
expected: "Path/MyRole",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_with_nested_path",
|
||||
roleArn: "arn:aws:iam::role/Division/Team/Role",
|
||||
expected: "Division/Team/Role",
|
||||
},
|
||||
// Standard AWS format (with account ID)
|
||||
{
|
||||
name: "standard_format_simple_role_name",
|
||||
roleArn: "arn:aws:iam::123456789012:role/default",
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "standard_format_custom_role_name",
|
||||
roleArn: "arn:aws:iam::999999999999:role/MyRole",
|
||||
expected: "MyRole",
|
||||
},
|
||||
{
|
||||
name: "standard_format_with_path",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Path/MyRole",
|
||||
expected: "Path/MyRole",
|
||||
},
|
||||
{
|
||||
name: "standard_format_with_nested_path",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Division/Team/Role",
|
||||
expected: "Division/Team/Role",
|
||||
},
|
||||
// Edge cases and invalid formats
|
||||
{
|
||||
name: "invalid_arn_missing_prefix",
|
||||
roleArn: "invalid-arn",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_incomplete",
|
||||
roleArn: "arn:aws:iam::",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_no_role_marker",
|
||||
roleArn: "arn:aws:iam::123456789012:user/username",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_wrong_service",
|
||||
roleArn: "arn:aws:sts::assumed-role/Role/Session",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty_string",
|
||||
roleArn: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "role_marker_no_name",
|
||||
roleArn: "arn:aws:iam::role/",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromArn(tc.roleArn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRoleNameFromPrincipal(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected string
|
||||
}{
|
||||
// STS assumed role format (legacy)
|
||||
{
|
||||
name: "sts_assumed_role_legacy",
|
||||
principal: "arn:aws:sts::assumed-role/RoleName/SessionName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_legacy_no_session",
|
||||
principal: "arn:aws:sts::assumed-role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
// STS assumed role format (standard with account ID)
|
||||
{
|
||||
name: "sts_assumed_role_standard",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/RoleName/SessionName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_standard_no_session",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
// IAM role format (legacy)
|
||||
{
|
||||
name: "iam_role_legacy",
|
||||
principal: "arn:aws:iam::role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "iam_role_legacy_with_path",
|
||||
principal: "arn:aws:iam::role/Path/RoleName",
|
||||
expected: "Path/RoleName",
|
||||
},
|
||||
// IAM role format (standard)
|
||||
{
|
||||
name: "iam_role_standard",
|
||||
principal: "arn:aws:iam::123456789012:role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "iam_role_standard_with_path",
|
||||
principal: "arn:aws:iam::123456789012:role/Path/RoleName",
|
||||
expected: "Path/RoleName",
|
||||
},
|
||||
// Invalid formats
|
||||
{
|
||||
name: "invalid_principal",
|
||||
principal: "invalid-arn",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty_string",
|
||||
principal: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromPrincipal(tc.principal)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromPrincipal(%q) = %q, want %q", tc.principal, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+118
-23
@@ -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()
|
||||
var cred *Credential
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
authInfo.AccessKey, len(availableKeys), iam.isAuthEnabled)
|
||||
|
||||
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()
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user