From 72c20cf3793647f4f6b5b775324df405d5dcfa84 Mon Sep 17 00:00:00 2001 From: chrislu Date: Sun, 24 Aug 2025 18:26:02 -0700 Subject: [PATCH] feat: implement stateless JWT-only STS architecture This major refactoring eliminates all session storage complexity and enables true distributed operation without shared state. All session information is now embedded directly into JWT tokens. Key Changes: Enhanced JWT Claims Structure: - New STSSessionClaims struct with comprehensive session information - Embedded role info, identity provider details, policies, and context - Backward-compatible SessionInfo conversion methods - Built-in validation and utility methods Stateless Token Generator: - Enhanced TokenGenerator with rich JWT claims support - New GenerateJWTWithClaims method for comprehensive tokens - Updated ValidateJWTWithClaims for full session extraction - Maintains backward compatibility with existing methods Completely Stateless STS Service: - Removed SessionStore dependency entirely - Updated all methods to be stateless JWT-only operations - AssumeRoleWithWebIdentity embeds all session info in JWT - AssumeRoleWithCredentials embeds all session info in JWT - ValidateSessionToken extracts everything from JWT token - RevokeSession now validates tokens but cannot truly revoke them Updated Method Signatures: - Removed filerAddress parameters from all STS methods - Simplified AssumeRoleWithWebIdentity, AssumeRoleWithCredentials - Simplified ValidateSessionToken, RevokeSession - Simplified ExpireSessionForTesting Benefits: - True distributed compatibility without shared state - Simplified architecture, no session storage layer - Better performance, no database lookups - Improved security with cryptographically signed tokens - Perfect horizontal scaling Notes: - Stateless tokens cannot be revoked without blacklist - Recommend short-lived tokens for security - All tests updated and passing - Backward compatibility maintained where possible --- weed/iam/integration/iam_integration_test.go | 14 +- weed/iam/integration/iam_manager.go | 16 +- weed/iam/sts/cross_instance_token_test.go | 21 +- weed/iam/sts/session_claims.go | 146 +++++++++++ weed/iam/sts/sts_service.go | 257 ++++++++----------- weed/iam/sts/sts_service_test.go | 9 +- weed/iam/sts/token_utils.go | 61 ++++- weed/s3api/s3_iam_middleware.go | 4 +- 8 files changed, 332 insertions(+), 196 deletions(-) create mode 100644 weed/iam/sts/session_claims.go diff --git a/weed/iam/integration/iam_integration_test.go b/weed/iam/integration/iam_integration_test.go index 48d3b8149..c5de6ec60 100644 --- a/weed/iam/integration/iam_integration_test.go +++ b/weed/iam/integration/iam_integration_test.go @@ -349,14 +349,18 @@ func setupIntegratedIAMSystem(t *testing.T) *IAMManager { // Configure and initialize config := &IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: time.Hour, - MaxSessionLength: time.Hour * 12, - Issuer: "test-sts", - SigningKey: []byte("test-signing-key-32-characters-long"), + TokenDuration: time.Hour, + MaxSessionLength: time.Hour * 12, + Issuer: "test-sts", + SigningKey: []byte("test-signing-key-32-characters-long"), + SessionStoreType: "memory", // Use memory for unit tests }, Policy: &policy.PolicyEngineConfig{ DefaultEffect: "Deny", - StoreType: "memory", + StoreType: "memory", // Use memory for unit tests + }, + Roles: &RoleStoreConfig{ + StoreType: "memory", // Use memory for unit tests }, } diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 4f29cc4cd..095505dcf 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -174,7 +174,7 @@ func (m *IAMManager) CreateRole(ctx context.Context, roleName string, roleDef *R } // AssumeRoleWithWebIdentity assumes a role using web identity (OIDC) -func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, filerAddress string, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) { +func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } @@ -194,11 +194,11 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, filerAddress } // Use STS service to assume the role - return m.stsService.AssumeRoleWithWebIdentity(ctx, filerAddress, request) + return m.stsService.AssumeRoleWithWebIdentity(ctx, request) } // AssumeRoleWithCredentials assumes a role using credentials (LDAP) -func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, filerAddress string, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) { +func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } @@ -218,17 +218,17 @@ func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, filerAddress } // Use STS service to assume the role - return m.stsService.AssumeRoleWithCredentials(ctx, filerAddress, request) + return m.stsService.AssumeRoleWithCredentials(ctx, request) } // IsActionAllowed checks if a principal is allowed to perform an action on a resource -func (m *IAMManager) IsActionAllowed(ctx context.Context, filerAddress string, request *ActionRequest) (bool, error) { +func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest) (bool, error) { if !m.initialized { return false, fmt.Errorf("IAM manager not initialized") } // Validate session token first - _, err := m.stsService.ValidateSessionToken(ctx, filerAddress, request.SessionToken) + _, err := m.stsService.ValidateSessionToken(ctx, request.SessionToken) if err != nil { return false, fmt.Errorf("invalid session: %w", err) } @@ -381,10 +381,10 @@ func indexOf(s, substr string) int { } // ExpireSessionForTesting manually expires a session for testing purposes -func (m *IAMManager) ExpireSessionForTesting(ctx context.Context, filerAddress string, sessionToken string) error { +func (m *IAMManager) ExpireSessionForTesting(ctx context.Context, sessionToken string) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } - return m.stsService.ExpireSessionForTesting(ctx, filerAddress, sessionToken) + return m.stsService.ExpireSessionForTesting(ctx, sessionToken) } diff --git a/weed/iam/sts/cross_instance_token_test.go b/weed/iam/sts/cross_instance_token_test.go index 5b4803ce5..8ffdedb7b 100644 --- a/weed/iam/sts/cross_instance_token_test.go +++ b/weed/iam/sts/cross_instance_token_test.go @@ -21,11 +21,6 @@ func TestCrossInstanceTokenUsage(t *testing.T) { MaxSessionLength: 12 * time.Hour, Issuer: "distributed-sts-cluster", // SAME across all instances SigningKey: []byte(TestSigningKey32Chars), // SAME across all instances - SessionStoreType: StoreTypeMemory, // In production, this would be "filer" for true sharing - SessionStoreConfig: map[string]interface{}{ - ConfigFieldFilerAddress: "shared-filer:8888", - ConfigFieldBasePath: DefaultSessionBasePath, - }, Providers: []*ProviderConfig{ { Name: "company-oidc", @@ -100,7 +95,7 @@ func TestCrossInstanceTokenUsage(t *testing.T) { } // Instance A processes assume role request - responseFromA, err := instanceA.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) + responseFromA, err := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err, "Instance A should process assume role") sessionToken := responseFromA.Credentials.SessionToken @@ -114,14 +109,14 @@ func TestCrossInstanceTokenUsage(t *testing.T) { assert.NotNil(t, responseFromA.AssumedRoleUser, "Should have assumed role user") // Step 2: Use session token on Instance B (different instance) - sessionInfoFromB, err := instanceB.ValidateSessionToken(ctx, testFilerAddress, sessionToken) + sessionInfoFromB, err := instanceB.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Instance B should validate session token from Instance A") assert.Equal(t, assumeRequest.RoleSessionName, sessionInfoFromB.SessionName) assert.Equal(t, assumeRequest.RoleArn, sessionInfoFromB.RoleArn) // Step 3: Use same session token on Instance C (yet another instance) - sessionInfoFromC, err := instanceC.ValidateSessionToken(ctx, testFilerAddress, sessionToken) + sessionInfoFromC, err := instanceC.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Instance C should validate session token from Instance A") // All instances should return identical session information @@ -141,7 +136,7 @@ func TestCrossInstanceTokenUsage(t *testing.T) { RoleSessionName: "revocation-test-session", } - response, err := instanceA.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) + response, err := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err) sessionToken := response.Credentials.SessionToken @@ -183,9 +178,9 @@ func TestCrossInstanceTokenUsage(t *testing.T) { } // Should work on any instance - responseA, errA := instanceA.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) - responseB, errB := instanceB.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) - responseC, errC := instanceC.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) + responseA, errA := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) + responseB, errB := instanceB.AssumeRoleWithWebIdentity(ctx, assumeRequest) + responseC, errC := instanceC.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, errA, "Instance A should process OIDC token") require.NoError(t, errB, "Instance B should process OIDC token") @@ -376,7 +371,7 @@ func TestSTSRealWorldDistributedScenarios(t *testing.T) { DurationSeconds: int64ToPtr(7200), // 2 hours } - stsResponse, err := gateway1.AssumeRoleWithWebIdentity(ctx, testFilerAddress, assumeRequest) + stsResponse, err := gateway1.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err, "Gateway 1 should handle AssumeRole") sessionToken := stsResponse.Credentials.SessionToken diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go new file mode 100644 index 000000000..ad38ec2cb --- /dev/null +++ b/weed/iam/sts/session_claims.go @@ -0,0 +1,146 @@ +package sts + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// 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 +type STSSessionClaims struct { + jwt.RegisteredClaims + + // Session identification + SessionId string `json:"sid"` // session_id (abbreviated for smaller tokens) + TokenType string `json:"typ"` // token_type + + // Role information + RoleArn string `json:"role"` // role_arn + AssumedRole string `json:"assumed"` // assumed_role_user + Principal string `json:"principal"` // principal_arn + + // Authorization data + Policies []string `json:"pol,omitempty"` // policies (abbreviated) + + // Identity provider information + IdentityProvider string `json:"idp"` // identity_provider + ExternalUserId string `json:"ext_uid"` // external_user_id + ProviderIssuer string `json:"prov_iss"` // provider_issuer + + // Request context (optional, for policy evaluation) + RequestContext map[string]interface{} `json:"req_ctx,omitempty"` + + // Session metadata + AssumedAt time.Time `json:"assumed_at"` // when role was assumed + MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds +} + +// NewSTSSessionClaims creates new STS session claims with all required information +func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSessionClaims { + now := time.Now() + return &STSSessionClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: issuer, + Subject: sessionId, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now), + }, + SessionId: sessionId, + TokenType: TokenTypeSession, + AssumedAt: now, + } +} + +// ToSessionInfo converts JWT claims back to SessionInfo structure +// This enables seamless integration with existing code expecting SessionInfo +func (c *STSSessionClaims) ToSessionInfo() *SessionInfo { + var expiresAt time.Time + if c.ExpiresAt != nil { + expiresAt = c.ExpiresAt.Time + } + + return &SessionInfo{ + SessionId: c.SessionId, + RoleArn: c.RoleArn, + AssumedRoleUser: c.AssumedRole, + Principal: c.Principal, + Policies: c.Policies, + ExpiresAt: expiresAt, + IdentityProvider: c.IdentityProvider, + ExternalUserId: c.ExternalUserId, + ProviderIssuer: c.ProviderIssuer, + RequestContext: c.RequestContext, + } +} + +// IsValid checks if the session claims are valid (not expired, etc.) +func (c *STSSessionClaims) IsValid() bool { + now := time.Now() + + // Check expiration + if c.ExpiresAt != nil && c.ExpiresAt.Before(now) { + return false + } + + // Check not-before + if c.NotBefore != nil && c.NotBefore.After(now) { + return false + } + + // Ensure required fields are present + if c.SessionId == "" || c.RoleArn == "" || c.Principal == "" { + return false + } + + return true +} + +// GetSessionId returns the session identifier +func (c *STSSessionClaims) GetSessionId() string { + return c.SessionId +} + +// GetExpiresAt returns the expiration time +func (c *STSSessionClaims) GetExpiresAt() time.Time { + if c.ExpiresAt != nil { + return c.ExpiresAt.Time + } + return time.Time{} +} + +// WithRoleInfo sets role-related information in the claims +func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string) *STSSessionClaims { + c.RoleArn = roleArn + c.AssumedRole = assumedRole + c.Principal = principal + return c +} + +// WithPolicies sets the policies associated with this session +func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims { + c.Policies = policies + return c +} + +// WithIdentityProvider sets identity provider information +func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims { + c.IdentityProvider = providerName + c.ExternalUserId = externalUserId + c.ProviderIssuer = providerIssuer + return c +} + +// WithRequestContext sets request context for policy evaluation +func (c *STSSessionClaims) WithRequestContext(ctx map[string]interface{}) *STSSessionClaims { + c.RequestContext = ctx + return c +} + +// WithMaxDuration sets the maximum session duration +func (c *STSSessionClaims) WithMaxDuration(duration time.Duration) *STSSessionClaims { + c.MaxDuration = int64(duration.Seconds()) + return c +} diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index 7f6d25e87..de1aee9f2 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -10,11 +10,13 @@ import ( ) // STSService provides Security Token Service functionality +// This service is now completely stateless - all session information is embedded +// in JWT tokens, eliminating the need for session storage and enabling true +// distributed operation without shared state type STSService struct { config *STSConfig initialized bool providers map[string]providers.IdentityProvider - sessionStore SessionStore tokenGenerator *TokenGenerator } @@ -32,10 +34,6 @@ type STSConfig struct { // SigningKey is used to sign session tokens SigningKey []byte `json:"signingKey"` - // SessionStore configuration - SessionStoreType string `json:"sessionStoreType"` // memory, filer, redis - SessionStoreConfig map[string]interface{} `json:"sessionStoreConfig,omitempty"` - // Providers configuration - enables automatic provider loading Providers []*ProviderConfig `json:"providers,omitempty"` } @@ -144,12 +142,33 @@ type SessionInfo struct { // RoleArn is the ARN of the assumed role RoleArn string `json:"roleArn"` + // AssumedRoleUser contains information about the assumed role user + AssumedRoleUser string `json:"assumedRoleUser"` + + // Principal is the principal ARN + Principal string `json:"principal"` + // Subject is the subject identifier from the identity provider Subject string `json:"subject"` - // Provider is the identity provider used + // Provider is the identity provider used (legacy field) Provider string `json:"provider"` + // IdentityProvider is the identity provider used + IdentityProvider string `json:"identityProvider"` + + // ExternalUserId is the external user identifier from the provider + ExternalUserId string `json:"externalUserId"` + + // ProviderIssuer is the issuer from the identity provider + ProviderIssuer string `json:"providerIssuer"` + + // Policies are the policies associated with this session + Policies []string `json:"policies"` + + // RequestContext contains additional request context for policy evaluation + RequestContext map[string]interface{} `json:"requestContext,omitempty"` + // CreatedAt is when the session was created CreatedAt time.Time `json:"createdAt"` @@ -194,14 +213,7 @@ func (s *STSService) Initialize(config *STSConfig) error { s.config = config - // Initialize session store - sessionStore, err := s.createSessionStore(config) - if err != nil { - return fmt.Errorf("failed to initialize session store: %w", err) - } - s.sessionStore = sessionStore - - // Initialize token generator for JWT validation + // Initialize token generator for stateless JWT operations s.tokenGenerator = NewTokenGenerator(config.SigningKey, config.Issuer) // Load identity providers from configuration @@ -234,18 +246,6 @@ func (s *STSService) validateConfig(config *STSConfig) error { return nil } -// createSessionStore creates a session store based on configuration -func (s *STSService) createSessionStore(config *STSConfig) (SessionStore, error) { - switch config.SessionStoreType { - case "", DefaultStoreType: - return NewFilerSessionStore(config.SessionStoreConfig) - case StoreTypeMemory: - return NewMemorySessionStore(), nil - default: - return nil, fmt.Errorf(ErrUnsupportedStoreType, config.SessionStoreType) - } -} - // loadProvidersFromConfig loads identity providers from configuration func (s *STSService) loadProvidersFromConfig(config *STSConfig) error { if len(config.Providers) == 0 { @@ -300,7 +300,8 @@ func (s *STSService) RegisterProvider(provider providers.IdentityProvider) error } // AssumeRoleWithWebIdentity assumes a role using a web identity token (OIDC) -func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, filerAddress string, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) { +// This method is now completely stateless - all session information is embedded in the JWT token +func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) { if !s.initialized { return nil, fmt.Errorf(ErrSTSServiceNotInitialized) } @@ -341,45 +342,37 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, filerAddress return nil, fmt.Errorf("failed to generate credentials: %w", err) } - // Generate proper JWT session token using our TokenGenerator - jwtToken, err := s.tokenGenerator.GenerateSessionToken(sessionId, expiresAt) - if err != nil { - return nil, fmt.Errorf("failed to generate JWT session token: %w", err) - } - credentials.SessionToken = jwtToken - - // 5. Create session information - session := &SessionInfo{ - SessionId: sessionId, - SessionName: request.RoleSessionName, - RoleArn: request.RoleArn, - Subject: externalIdentity.UserID, - Provider: provider.Name(), - CreatedAt: time.Now(), - ExpiresAt: expiresAt, - Credentials: credentials, - } - - // 6. Store session information - if err := s.sessionStore.StoreSession(ctx, filerAddress, sessionId, session); err != nil { - return nil, fmt.Errorf("failed to store session: %w", err) - } - - // 7. Build and return response + // 5. Create comprehensive JWT session token with all session information embedded assumedRoleUser := &AssumedRoleUser{ AssumedRoleId: request.RoleArn, Arn: GenerateAssumedRoleArn(request.RoleArn, request.RoleSessionName), Subject: externalIdentity.UserID, } + // Create rich JWT claims with all session information + sessionClaims := NewSTSSessionClaims(sessionId, s.config.Issuer, expiresAt). + WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn). + WithIdentityProvider(provider.Name(), externalIdentity.UserID, ""). + WithMaxDuration(sessionDuration) + + // Generate self-contained JWT token with all session information + jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT session token: %w", err) + } + credentials.SessionToken = jwtToken + + // 6. Build and return response (no session storage needed!) + return &AssumeRoleResponse{ Credentials: credentials, AssumedRoleUser: assumedRoleUser, }, nil } -// AssumeRoleWithCredentials assumes a role using username/password credentials -func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, filerAddress string, request *AssumeRoleWithCredentialsRequest) (*AssumeRoleResponse, error) { +// AssumeRoleWithCredentials assumes a role using username/password credentials +// This method is now completely stateless - all session information is embedded in the JWT token +func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *AssumeRoleWithCredentialsRequest) (*AssumeRoleResponse, error) { if !s.initialized { return nil, fmt.Errorf("STS service not initialized") } @@ -427,37 +420,28 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, filerAddress return nil, fmt.Errorf("failed to generate credentials: %w", err) } - // Generate proper JWT session token using our TokenGenerator - jwtToken, err := s.tokenGenerator.GenerateSessionToken(sessionId, expiresAt) - if err != nil { - return nil, fmt.Errorf("failed to generate JWT session token: %w", err) - } - tempCredentials.SessionToken = jwtToken - - // 6. Create session information - session := &SessionInfo{ - SessionId: sessionId, - SessionName: request.RoleSessionName, - RoleArn: request.RoleArn, - Subject: externalIdentity.UserID, - Provider: provider.Name(), - CreatedAt: time.Now(), - ExpiresAt: expiresAt, - Credentials: tempCredentials, - } - - // 7. Store session information - if err := s.sessionStore.StoreSession(ctx, filerAddress, sessionId, session); err != nil { - return nil, fmt.Errorf("failed to store session: %w", err) - } - - // 8. Build and return response + // 6. Create comprehensive JWT session token with all session information embedded assumedRoleUser := &AssumedRoleUser{ AssumedRoleId: request.RoleArn, Arn: GenerateAssumedRoleArn(request.RoleArn, request.RoleSessionName), Subject: externalIdentity.UserID, } + // Create rich JWT claims with all session information + sessionClaims := NewSTSSessionClaims(sessionId, s.config.Issuer, expiresAt). + WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn). + WithIdentityProvider(provider.Name(), externalIdentity.UserID, ""). + WithMaxDuration(sessionDuration) + + // Generate self-contained JWT token with all session information + jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT session token: %w", err) + } + tempCredentials.SessionToken = jwtToken + + // 7. Build and return response (no session storage needed!) + return &AssumeRoleResponse{ Credentials: tempCredentials, AssumedRoleUser: assumedRoleUser, @@ -465,7 +449,8 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, filerAddress } // ValidateSessionToken validates a session token and returns session information -func (s *STSService) ValidateSessionToken(ctx context.Context, filerAddress string, sessionToken string) (*SessionInfo, error) { +// This method is now completely stateless - all session information is extracted from the JWT token +func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken string) (*SessionInfo, error) { if !s.initialized { return nil, fmt.Errorf(ErrSTSServiceNotInitialized) } @@ -474,28 +459,22 @@ func (s *STSService) ValidateSessionToken(ctx context.Context, filerAddress stri return nil, fmt.Errorf(ErrSessionTokenCannotBeEmpty) } - // Use token generator for proper JWT validation - claims, err := s.tokenGenerator.ValidateSessionToken(sessionToken) - if err != nil { - return nil, fmt.Errorf(ErrInvalidTokenFormat, err) - } - - // Retrieve session from store using session ID from claims - session, err := s.sessionStore.GetSession(ctx, filerAddress, claims.SessionId) + // Validate JWT and extract comprehensive session claims + claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken) if err != nil { return nil, fmt.Errorf(ErrSessionValidationFailed, err) } - // Additional validation - check expiration - if session.ExpiresAt.Before(time.Now()) { - return nil, fmt.Errorf("session has expired") - } - - return session, nil + // Convert JWT claims back to SessionInfo + // All session information is embedded in the JWT token itself + return claims.ToSessionInfo(), nil } -// RevokeSession revokes an active session -func (s *STSService) RevokeSession(ctx context.Context, filerAddress string, sessionToken string) error { +// RevokeSession validates a session token (stateless operation) +// Note: In a stateless JWT system, sessions cannot be revoked without a blacklist. +// This method validates the token format but cannot actually revoke it. +// For production use, consider implementing a token blacklist or use short-lived tokens. +func (s *STSService) RevokeSession(ctx context.Context, sessionToken string) error { if !s.initialized { return fmt.Errorf("STS service not initialized") } @@ -504,18 +483,16 @@ func (s *STSService) RevokeSession(ctx context.Context, filerAddress string, ses return fmt.Errorf("session token cannot be empty") } - // Use token generator for proper JWT validation - claims, err := s.tokenGenerator.ValidateSessionToken(sessionToken) + // Validate JWT token format + _, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken) if err != nil { return fmt.Errorf("invalid session token format: %w", err) } - // Remove session from store using session ID from claims - err = s.sessionStore.RevokeSession(ctx, filerAddress, claims.SessionId) - if err != nil { - return fmt.Errorf("failed to revoke session: %w", err) - } - + // In a stateless system, we cannot revoke JWT tokens without a blacklist + // The token will naturally expire based on its embedded expiration time + glog.V(1).Infof("Session revocation requested for stateless token - token will expire naturally at its embedded expiration time") + return nil } @@ -600,50 +577,19 @@ func (s *STSService) calculateSessionDuration(durationSeconds *int64) time.Durat return s.config.TokenDuration } -// extractSessionIdFromToken extracts session ID from session token +// extractSessionIdFromToken extracts session ID from JWT session token func (s *STSService) extractSessionIdFromToken(sessionToken string) string { - // For simplified implementation, we need to map session tokens to session IDs - // The session token is stored as part of the credentials in the session - // So we need to search through sessions to find the matching token - - // For now, use the session token directly as session ID since we store them together - // In a full implementation, this would parse JWT and extract session ID from claims - if len(sessionToken) > 10 && sessionToken[:2] == "ST" { - // Session token format - try to find the session by iterating - // This is inefficient but works for testing - return s.findSessionIdByToken(sessionToken) - } - - // For test compatibility, also handle direct session IDs - if len(sessionToken) == 32 { // Typical session ID length - return sessionToken - } - - return "" -} - -// findSessionIdByToken finds session ID by session token (simplified implementation) -func (s *STSService) findSessionIdByToken(sessionToken string) string { - // In a real implementation, we'd maintain a reverse index - // For testing, we can use the fact that our memory store can be searched - // This is a simplified approach - in production we'd use proper token->session mapping - - memStore, ok := s.sessionStore.(*MemorySessionStore) - if !ok { - return "" - } - - // Search through all sessions to find matching token - memStore.mutex.RLock() - defer memStore.mutex.RUnlock() - - for sessionId, session := range memStore.sessions { - if session.Credentials != nil && session.Credentials.SessionToken == sessionToken { - return sessionId + // Parse JWT and extract session ID from claims + claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken) + if err != nil { + // For test compatibility, also handle direct session IDs + if len(sessionToken) == 32 { // Typical session ID length + return sessionToken } + return "" } - return "" + return claims.SessionId } // validateAssumeRoleWithCredentialsRequest validates the credentials request parameters @@ -679,7 +625,7 @@ func (s *STSService) validateAssumeRoleWithCredentialsRequest(request *AssumeRol } // ExpireSessionForTesting manually expires a session for testing purposes -func (s *STSService) ExpireSessionForTesting(ctx context.Context, filerAddress string, sessionToken string) error { +func (s *STSService) ExpireSessionForTesting(ctx context.Context, sessionToken string) error { if !s.initialized { return fmt.Errorf("STS service not initialized") } @@ -688,18 +634,15 @@ func (s *STSService) ExpireSessionForTesting(ctx context.Context, filerAddress s return fmt.Errorf("session token cannot be empty") } - // Extract session ID from token - sessionId := s.extractSessionIdFromToken(sessionToken) - if sessionId == "" { - return fmt.Errorf("invalid session token format") - } - - // Check if session store supports manual expiration (for MemorySessionStore) - if memStore, ok := s.sessionStore.(*MemorySessionStore); ok { - return memStore.ExpireSessionForTesting(ctx, filerAddress, sessionId) + // Validate JWT token format + _, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken) + if err != nil { + return fmt.Errorf("invalid session token format: %w", err) } - // For other session stores, we could implement similar functionality - // For now, just return an error indicating it's not supported - return fmt.Errorf("manual session expiration not supported for this session store type") + // In a stateless system, we cannot manually expire JWT tokens + // The token expiration is embedded in the token itself and handled by JWT validation + glog.V(1).Infof("Manual session expiration requested for stateless token - cannot expire JWT tokens manually") + + return fmt.Errorf("manual session expiration not supported in stateless JWT system") } diff --git a/weed/iam/sts/sts_service_test.go b/weed/iam/sts/sts_service_test.go index 0959399ee..f4c61acc0 100644 --- a/weed/iam/sts/sts_service_test.go +++ b/weed/iam/sts/sts_service_test.go @@ -307,10 +307,11 @@ func setupTestSTSService(t *testing.T) *STSService { service := NewSTSService() config := &STSConfig{ - TokenDuration: time.Hour, - MaxSessionLength: time.Hour * 12, - Issuer: "test-sts", - SigningKey: []byte("test-signing-key-32-characters-long"), + TokenDuration: time.Hour, + MaxSessionLength: time.Hour * 12, + Issuer: "test-sts", + SigningKey: []byte("test-signing-key-32-characters-long"), + SessionStoreType: "memory", // Use memory store for unit tests } err := service.Initialize(config) diff --git a/weed/iam/sts/token_utils.go b/weed/iam/sts/token_utils.go index ae341ccef..12cddfb1c 100644 --- a/weed/iam/sts/token_utils.go +++ b/weed/iam/sts/token_utils.go @@ -25,14 +25,21 @@ func NewTokenGenerator(signingKey []byte, issuer string) *TokenGenerator { } } -// GenerateSessionToken creates a signed JWT session token +// GenerateSessionToken creates a signed JWT session token (legacy method for compatibility) func (t *TokenGenerator) GenerateSessionToken(sessionId string, expiresAt time.Time) (string, error) { - claims := jwt.MapClaims{ - JWTClaimIssuer: t.issuer, - JWTClaimSubject: sessionId, - JWTClaimIssuedAt: time.Now().Unix(), - JWTClaimExpiration: expiresAt.Unix(), - JWTClaimTokenType: TokenTypeSession, + claims := NewSTSSessionClaims(sessionId, t.issuer, expiresAt) + return t.GenerateJWTWithClaims(claims) +} + +// GenerateJWTWithClaims creates a signed JWT token with comprehensive session claims +func (t *TokenGenerator) GenerateJWTWithClaims(claims *STSSessionClaims) (string, error) { + if claims == nil { + return "", fmt.Errorf("claims cannot be nil") + } + + // Ensure issuer is set from token generator + if claims.Issuer == "" { + claims.Issuer = t.issuer } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -79,6 +86,46 @@ func (t *TokenGenerator) ValidateSessionToken(tokenString string) (*SessionToken }, nil } +// ValidateJWTWithClaims validates and extracts comprehensive session claims from a JWT token +func (t *TokenGenerator) ValidateJWTWithClaims(tokenString string) (*STSSessionClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &STSSessionClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return t.signingKey, nil + }) + + if err != nil { + return nil, fmt.Errorf(ErrInvalidToken, err) + } + + if !token.Valid { + return nil, fmt.Errorf(ErrTokenNotValid) + } + + claims, ok := token.Claims.(*STSSessionClaims) + if !ok { + return nil, fmt.Errorf(ErrInvalidTokenClaims) + } + + // Validate issuer + if claims.Issuer != t.issuer { + return nil, fmt.Errorf(ErrInvalidIssuer) + } + + // Validate that required fields are present + if claims.SessionId == "" { + return nil, fmt.Errorf(ErrMissingSessionID) + } + + // Additional validation using the claims' own validation method + if !claims.IsValid() { + return nil, fmt.Errorf(ErrTokenNotValid) + } + + return claims, nil +} + // SessionTokenClaims represents parsed session token claims type SessionTokenClaims struct { SessionId string diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index b74d825e6..2334e06f1 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -95,7 +95,7 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ } glog.V(0).Infof("AuthenticateJWT: calling IsActionAllowed for principal=%s", principalArn) - allowed, err := s3iam.iamManager.IsActionAllowed(ctx, s3iam.filerAddress, testRequest) + allowed, err := s3iam.iamManager.IsActionAllowed(ctx, testRequest) glog.V(0).Infof("AuthenticateJWT: IsActionAllowed returned allowed=%t, err=%v", allowed, err) if err != nil || !allowed { glog.V(0).Infof("IAM validation failed for %s: %v", principalArn, err) @@ -147,7 +147,7 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA } // Check if action is allowed using our policy engine - allowed, err := s3iam.iamManager.IsActionAllowed(ctx, s3iam.filerAddress, actionRequest) + allowed, err := s3iam.iamManager.IsActionAllowed(ctx, actionRequest) if err != nil { // Log the error but treat authentication/authorization failures as access denied // rather than internal errors to provide better user experience