diff --git a/weed/iam/oidc/oidc_provider.go b/weed/iam/oidc/oidc_provider.go index 1f5db8605..b95b17fbd 100644 --- a/weed/iam/oidc/oidc_provider.go +++ b/weed/iam/oidc/oidc_provider.go @@ -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, diff --git a/weed/iam/oidc/oidc_provider_test.go b/weed/iam/oidc/oidc_provider_test.go index d8624ac30..5a96c2c86 100644 --- a/weed/iam/oidc/oidc_provider_test.go +++ b/weed/iam/oidc/oidc_provider_test.go @@ -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) diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 3f22f33e7..3982ed2af 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -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 }