Fix: Propagate OIDC claims for dynamic IAM policies (#8060)
Fix: Propagate OIDC claims to IAM identity for dynamic policy variables Fixes #8037. Ensures additional OIDC claims (like preferred_username) are preserved in ExternalIdentity attributes and propagated to IAM tokens, enabling substitution in dynamic policies.
This commit is contained in:
@@ -237,6 +237,34 @@ func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*provide
|
||||
attributes["roles"] = strings.Join(roles, ",")
|
||||
}
|
||||
|
||||
// Store all additional claims as attributes
|
||||
processedClaims := map[string]struct{}{
|
||||
// user / business claims already handled elsewhere
|
||||
"sub": {},
|
||||
"email": {},
|
||||
"name": {},
|
||||
"groups": {},
|
||||
"roles": {},
|
||||
// standard structural OIDC/JWT claims that should not be exposed as attributes
|
||||
"iss": {},
|
||||
"aud": {},
|
||||
"exp": {},
|
||||
"iat": {},
|
||||
"nbf": {},
|
||||
"jti": {},
|
||||
}
|
||||
for key, value := range claims.Claims {
|
||||
if _, isProcessed := processedClaims[key]; !isProcessed {
|
||||
if strValue, ok := value.(string); ok {
|
||||
attributes[key] = strValue
|
||||
} else if jsonValue, err := json.Marshal(value); err == nil {
|
||||
attributes[key] = string(jsonValue)
|
||||
} else {
|
||||
glog.Warningf("failed to marshal claim %q to JSON for OIDC attributes: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
|
||||
@@ -248,6 +248,60 @@ func TestOIDCProviderAuthentication(t *testing.T) {
|
||||
assert.Contains(t, identity.Groups, "developers")
|
||||
})
|
||||
|
||||
t.Run("successful authentication with additional attributes", func(t *testing.T) {
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users"},
|
||||
"preferred_username": "myusername", // Extra claim
|
||||
"department": "engineering", // Extra claim
|
||||
"custom_number": 42, // Non-string claim
|
||||
"custom_avg": 98.6, // Non-string claim
|
||||
"custom_object": map[string]interface{}{"nested": "value"}, // Nested object claim
|
||||
})
|
||||
|
||||
identity, err := provider.Authenticate(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
|
||||
// Check standard fields
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
|
||||
// Check attributes
|
||||
val, exists := identity.Attributes["preferred_username"]
|
||||
assert.True(t, exists, "preferred_username should be in attributes")
|
||||
assert.Equal(t, "myusername", val)
|
||||
|
||||
val, exists = identity.Attributes["department"]
|
||||
assert.True(t, exists, "department should be in attributes")
|
||||
assert.Equal(t, "engineering", val)
|
||||
|
||||
// Test non-string claims (should be JSON marshaled)
|
||||
val, exists = identity.Attributes["custom_number"]
|
||||
assert.True(t, exists, "custom_number should be in attributes")
|
||||
assert.Equal(t, "42", val)
|
||||
|
||||
val, exists = identity.Attributes["custom_avg"]
|
||||
assert.True(t, exists, "custom_avg should be in attributes")
|
||||
assert.Contains(t, val, "98.6") // JSON number formatting might vary
|
||||
|
||||
val, exists = identity.Attributes["custom_object"]
|
||||
assert.True(t, exists, "custom_object should be in attributes")
|
||||
assert.Contains(t, val, "\"nested\":\"value\"")
|
||||
|
||||
// Verify structural JWT claims are excluded from attributes
|
||||
excludedClaims := []string{"iss", "aud", "exp", "iat"}
|
||||
for _, claim := range excludedClaims {
|
||||
_, exists := identity.Attributes[claim]
|
||||
assert.False(t, exists, "standard claim %s should not be in attributes", claim)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authentication with invalid token", func(t *testing.T) {
|
||||
_, err := provider.Authenticate(context.Background(), "invalid-token")
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -133,20 +133,53 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Create claims map and populate with standard claims and attributes
|
||||
claims := make(map[string]interface{}, len(identity.Attributes)+5)
|
||||
|
||||
// Add all attributes from the identity to the claims
|
||||
// This makes attributes like "preferred_username" available for policy substitution
|
||||
for k, v := range identity.Attributes {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
// Add standard OIDC fields to claims so they are available as variables
|
||||
// This ensures ${jwt:email}, ${jwt:name}, etc. work as documented in the wiki
|
||||
if identity.Email != "" {
|
||||
claims["email"] = identity.Email
|
||||
}
|
||||
if identity.DisplayName != "" {
|
||||
claims["name"] = identity.DisplayName
|
||||
}
|
||||
if len(identity.Groups) > 0 {
|
||||
claims["groups"] = identity.Groups
|
||||
}
|
||||
|
||||
// Set critical claims explicitly, overwriting any from attributes to ensure correctness.
|
||||
claims["sub"] = identity.UserID
|
||||
claims["role"] = identity.RoleArn
|
||||
|
||||
// Use real email address if available
|
||||
emailAddress := identity.UserID + "@oidc.local"
|
||||
if identity.Email != "" {
|
||||
emailAddress = identity.Email
|
||||
}
|
||||
|
||||
displayName := identity.UserID
|
||||
if identity.DisplayName != "" {
|
||||
displayName = identity.DisplayName
|
||||
}
|
||||
|
||||
// Return IAM identity for OIDC token
|
||||
return &IAMIdentity{
|
||||
Name: identity.UserID,
|
||||
Principal: identity.RoleArn,
|
||||
SessionToken: sessionToken,
|
||||
Account: &Account{
|
||||
DisplayName: identity.UserID,
|
||||
EmailAddress: identity.UserID + "@oidc.local",
|
||||
DisplayName: displayName,
|
||||
EmailAddress: emailAddress,
|
||||
Id: identity.UserID,
|
||||
},
|
||||
Claims: map[string]interface{}{
|
||||
"sub": identity.UserID,
|
||||
"role": identity.RoleArn,
|
||||
},
|
||||
Claims: claims,
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
@@ -655,9 +688,13 @@ func (enhanced *EnhancedS3ApiServer) AuthorizeRequest(r *http.Request, identity
|
||||
|
||||
// OIDCIdentity represents an identity validated through OIDC
|
||||
type OIDCIdentity struct {
|
||||
UserID string
|
||||
RoleArn string
|
||||
Provider string
|
||||
UserID string
|
||||
RoleArn string
|
||||
Provider string
|
||||
Email string
|
||||
DisplayName string
|
||||
Groups []string
|
||||
Attributes map[string]string
|
||||
}
|
||||
|
||||
// validateExternalOIDCToken validates an external OIDC token using the STS service's secure issuer-based lookup
|
||||
@@ -714,9 +751,13 @@ func (s3iam *S3IAMIntegration) validateExternalOIDCToken(ctx context.Context, to
|
||||
roleArn := s3iam.selectPrimaryRole(cleanRoles, externalIdentity)
|
||||
|
||||
return &OIDCIdentity{
|
||||
UserID: externalIdentity.UserID,
|
||||
RoleArn: roleArn,
|
||||
Provider: fmt.Sprintf("%T", provider), // Use provider type as identifier
|
||||
UserID: externalIdentity.UserID,
|
||||
RoleArn: roleArn,
|
||||
Provider: fmt.Sprintf("%T", provider), // Use provider type as identifier
|
||||
Email: externalIdentity.Email,
|
||||
DisplayName: externalIdentity.DisplayName,
|
||||
Groups: externalIdentity.Groups,
|
||||
Attributes: externalIdentity.Attributes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user