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
This commit is contained in:
Chris Lu
2026-09-17 23:50:16 -07:00
committed by GitHub
parent 15520f601f
commit f40687b34e
19 changed files with 278 additions and 165 deletions
+45 -37
View File
@@ -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 {
+8 -5
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+9 -16
View File
@@ -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{
+10 -8
View File
@@ -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)
+10 -5
View File
@@ -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.
+15 -13
View File
@@ -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
+121
View File
@@ -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")
}
+1 -1
View File
@@ -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)
+18 -24
View File
@@ -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)
+1 -3
View File
@@ -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 {
+6 -45
View File
@@ -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
+13 -3
View File
@@ -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)
+3
View File
@@ -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
}
+2 -2
View File
@@ -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)
+6
View File
@@ -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
}
+4
View File
@@ -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
@@ -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
}