From 2bd42e8fc4a6854564d23e6617e2f46309448bde Mon Sep 17 00:00:00 2001 From: Livio Spring <9405495+livio-a@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:44:15 +0200 Subject: [PATCH] fix(actions): allow adding raw metadata values via appendMetadataRaw (#12567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Since #10666 (v4.1+, backported to v4.x), metadata values set through actions v1 (`api.metadata.push` and `api.v1.user.appendMetadata`) are always JSON-encoded via `json.Marshal`. This made the write path consistent with the JSON-based read path, but removed the ability to store raw (unencoded) metadata values: - A scalar string is now always stored quoted (`"de"` instead of `de`). - The previous byte-array convention (mapping a string to an integer array in the script, handled by `mapBytesToByteArray` introduced in #5526) now stores the literal integer-array text (e.g. `[100,101]`) instead of the raw bytes. Customers migrating from v3.x whose downstream systems base64-decode metadata values from tokens and expect raw bytes have no way to produce them anymore — changing the consuming system is not always possible. Reverting the default is not an option either, as clients that adopted actions v1 on v4.x now rely on the JSON encoding. # How the Problems Are Solved - Adds a new, opt-in function `api.v1.user.appendMetadataRaw(key, value)` to the actions v1 login flows (external / internal authentication post authentication and pre creation), next to the existing `appendMetadata`. - The value is stored as raw bytes without JSON encoding: - a string is stored as its plain UTF-8 bytes (`de`, not `"de"`) - byte arrays (`Uint8Array` or a plain array of integers 0-255, the old convention) are stored as-is, so existing v3 scripts using a string-to-byte-array helper only need to switch the function name - other types (and empty values) throw an error - The existing `appendMetadata` and `api.metadata.push` behavior remains byte-for-byte unchanged. # Additional Changes - Documented `appendMetadataRaw` (and the JSON encoding behavior of `appendMetadata`) in the external and internal authentication actions docs. - Added unit tests for the new function (through a real goja runtime) and a test locking in the existing `appendMetadata` JSON-encoding behavior. # Additional Context - Regression introduced as a side effect of #10666 (which fixed #10470); the raw byte handling was originally introduced in #5526. - Reported by a customer upgrading from v3.4.x to v4.16.x, whose PostAuthentication action maps token payload claims into user metadata. - Requires backport to v4.x. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 --- .../apis/actions/external-authentication.mdx | 14 +- .../apis/actions/internal-authentication.mdx | 14 +- internal/actions/object/metadata.go | 47 +++++++ internal/actions/object/metadata_test.go | 126 ++++++++++++++++++ internal/api/ui/login/custom_action.go | 3 + 5 files changed, 200 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/apis/actions/external-authentication.mdx b/apps/docs/content/apis/actions/external-authentication.mdx index 39aa4a832..0e2b01d76 100644 --- a/apps/docs/content/apis/actions/external-authentication.mdx +++ b/apps/docs/content/apis/actions/external-authentication.mdx @@ -43,7 +43,12 @@ The first parameter contains the following fields - `v1` - `user` - `appendMetadata(string, Any)` - The first parameter represents the key and the second a value which will be stored + The first parameter represents the key and the second a value which will be stored. + The value is JSON-encoded, so a string is stored with surrounding quotes (e.g. `"de"`). + - `appendMetadataRaw(string, string | Uint8Array | number[])` + The first parameter represents the key and the second a value which will be stored as raw bytes without JSON encoding. + A string is stored as its plain UTF-8 bytes (e.g. `de` without quotes), a byte array (`Uint8Array` or an array of integers between 0 and 255) is stored as-is. + Use this if the systems consuming the metadata expect an unencoded value. - `setFirstName(string)` Sets the first name - `setLastName(string)` @@ -113,7 +118,12 @@ The trigger is represented by the following Ids in the API: `TRIGGER_TYPE_PRE_CR - `v1` - `user` - `appendMetadata(string, Any)` - The first parameter represents the key and the second a value which will be stored + The first parameter represents the key and the second a value which will be stored. + The value is JSON-encoded, so a string is stored with surrounding quotes (e.g. `"de"`). + - `appendMetadataRaw(string, string | Uint8Array | number[])` + The first parameter represents the key and the second a value which will be stored as raw bytes without JSON encoding. + A string is stored as its plain UTF-8 bytes (e.g. `de` without quotes), a byte array (`Uint8Array` or an array of integers between 0 and 255) is stored as-is. + Use this if the systems consuming the metadata expect an unencoded value. ## Post Creation diff --git a/apps/docs/content/apis/actions/internal-authentication.mdx b/apps/docs/content/apis/actions/internal-authentication.mdx index 51a2f6366..7654d2919 100644 --- a/apps/docs/content/apis/actions/internal-authentication.mdx +++ b/apps/docs/content/apis/actions/internal-authentication.mdx @@ -33,7 +33,12 @@ The trigger is represented by the following Ids in the API: `TRIGGER_TYPE_POST_A - `v1` - `user` - `appendMetadata(string, Any)` - The first parameter represents the key and the second a value which will be stored + The first parameter represents the key and the second a value which will be stored. + The value is JSON-encoded, so a string is stored with surrounding quotes (e.g. `"de"`). + - `appendMetadataRaw(string, string | Uint8Array | number[])` + The first parameter represents the key and the second a value which will be stored as raw bytes without JSON encoding. + A string is stored as its plain UTF-8 bytes (e.g. `de` without quotes), a byte array (`Uint8Array` or an array of integers between 0 and 255) is stored as-is. + Use this if the systems consuming the metadata expect an unencoded value. ## Pre Creation @@ -80,7 +85,12 @@ The trigger is represented by the following Ids in the API: `TRIGGER_TYPE_PRE_CR - `v1` - `user` - `appendMetadata(string, Any)` - The first parameter represents the key and the second a value which will be stored + The first parameter represents the key and the second a value which will be stored. + The value is JSON-encoded, so a string is stored with surrounding quotes (e.g. `"de"`). + - `appendMetadataRaw(string, string | Uint8Array | number[])` + The first parameter represents the key and the second a value which will be stored as raw bytes without JSON encoding. + A string is stored as its plain UTF-8 bytes (e.g. `de` without quotes), a byte array (`Uint8Array` or an array of integers between 0 and 255) is stored as-is. + Use this if the systems consuming the metadata expect an unencoded value. ## Post Creation diff --git a/internal/actions/object/metadata.go b/internal/actions/object/metadata.go index 483f109b4..9c0c558f7 100644 --- a/internal/actions/object/metadata.go +++ b/internal/actions/object/metadata.go @@ -176,6 +176,53 @@ func (md *MetadataList) AppendMetadataFunc(call goja.FunctionCall) goja.Value { return nil } +// AppendMetadataRawFunc appends a metadata entry storing the value as raw bytes +// without JSON encoding. In contrast to [MetadataList.AppendMetadataFunc], a string +// is stored as its plain UTF-8 bytes (e.g. `de` instead of `"de"`). +// Allowed values are strings and byte arrays (Uint8Array or an array of integers 0-255). +func (md *MetadataList) AppendMetadataRawFunc(call goja.FunctionCall) goja.Value { + if len(call.Arguments) != 2 { + panic("exactly 2 (key, value) arguments expected") + } + + value := rawMetadataValue(call.Arguments[1].Export()) + if len(value) == 0 { + panic("value must not be empty") + } + + md.metadata = append(md.metadata, + &Metadata{ + Key: call.Arguments[0].Export().(string), + Value: call.Arguments[1], + value: value, + }) + return nil +} + +// rawMetadataValue converts an exported goja value to raw bytes. +// Strings are converted to their UTF-8 bytes, Uint8Array is exported as []byte directly +// and plain arrays must only contain integers in the range 0-255. +func rawMetadataValue(v interface{}) []byte { + switch value := v.(type) { + case string: + return []byte(value) + case []byte: + return value + case []interface{}: + bytes := make([]byte, len(value)) + for i, item := range value { + b, ok := item.(int64) + if !ok || b < 0 || b > 255 { + panic("array value must only contain integers between 0 and 255") + } + bytes[i] = byte(b) + } + return bytes + default: + panic("value must be a string or byte array") + } +} + func (md *MetadataList) MetadataListFromDomain(runtime *goja.Runtime) interface{} { for i, metadata := range md.metadata { md.metadata[i].Value = metadataByteArrayToValue(metadata.value, runtime) diff --git a/internal/actions/object/metadata_test.go b/internal/actions/object/metadata_test.go index 80dccfe46..894b8565d 100644 --- a/internal/actions/object/metadata_test.go +++ b/internal/actions/object/metadata_test.go @@ -5,6 +5,7 @@ import ( "github.com/dop251/goja" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" ) @@ -120,3 +121,128 @@ func TestMetadataListToDomain(t *testing.T) { }) } } + +func TestAppendMetadataRawFunc(t *testing.T) { + tests := []struct { + name string + script string + want []*domain.Metadata + }{ + { + name: "string is stored as raw bytes without quotes", + script: `appendMetadataRaw("locale", "de")`, + want: []*domain.Metadata{ + { + Key: "locale", + Value: []byte("de"), + }, + }, + }, + { + name: "json string is stored verbatim", + script: `appendMetadataRaw("tokenPayload", '{"acr":"phrh"}')`, + want: []*domain.Metadata{ + { + Key: "tokenPayload", + Value: []byte(`{"acr":"phrh"}`), + }, + }, + }, + { + name: "uint8 array is stored as raw bytes", + script: `appendMetadataRaw("locale", new Uint8Array([100, 101]))`, + want: []*domain.Metadata{ + { + Key: "locale", + Value: []byte("de"), + }, + }, + }, + { + name: "integer array is stored as raw bytes", + script: `appendMetadataRaw("locale", [100, 101])`, + want: []*domain.Metadata{ + { + Key: "locale", + Value: []byte("de"), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := goja.New() + metadataList := &MetadataList{} + require.NoError(t, runtime.Set("appendMetadataRaw", metadataList.AppendMetadataRawFunc)) + _, err := runtime.RunString(tt.script) + require.NoError(t, err) + assert.Equal(t, tt.want, MetadataListToDomain(metadataList)) + }) + } +} + +func TestAppendMetadataRawFuncPanics(t *testing.T) { + runtime := goja.New() + tests := []struct { + name string + args []goja.Value + }{ + { + name: "wrong argument count", + args: []goja.Value{runtime.ToValue("locale")}, + }, + { + name: "empty string value", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue("")}, + }, + { + name: "empty array value", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{})}, + }, + { + name: "number value", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue(1)}, + }, + { + name: "object value", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue(map[string]interface{}{"locale": "de"})}, + }, + { + name: "array value out of byte range", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{int64(300)})}, + }, + { + name: "array value with non integer", + args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{1.5})}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metadataList := &MetadataList{} + assert.Panics(t, func() { + metadataList.AppendMetadataRawFunc(goja.FunctionCall{Arguments: tt.args}) + }) + }) + } +} + +// TestAppendMetadataFuncJSONEncodes locks in that the existing appendMetadata +// still JSON-encodes its value (a string is stored quoted), in contrast to appendMetadataRaw. +func TestAppendMetadataFuncJSONEncodes(t *testing.T) { + runtime := goja.New() + metadataList := &MetadataList{} + require.NoError(t, runtime.Set("appendMetadata", metadataList.AppendMetadataFunc)) + require.NoError(t, runtime.Set("appendMetadataRaw", metadataList.AppendMetadataRawFunc)) + _, err := runtime.RunString(`appendMetadata("locale", "de"); appendMetadataRaw("locale", "de")`) + require.NoError(t, err) + assert.Equal(t, []*domain.Metadata{ + { + Key: "locale", + Value: []byte(`"de"`), + }, + { + Key: "locale", + Value: []byte(`de`), + }, + }, MetadataListToDomain(metadataList)) +} diff --git a/internal/api/ui/login/custom_action.go b/internal/api/ui/login/custom_action.go index 29874196c..a0ffecbe9 100644 --- a/internal/api/ui/login/custom_action.go +++ b/internal/api/ui/login/custom_action.go @@ -99,6 +99,7 @@ func (l *Login) runPostExternalAuthenticationActions( actions.SetFields("v1", actions.SetFields("user", actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc), + actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc), ), ), ) @@ -201,6 +202,7 @@ func (l *Login) runPostInternalAuthenticationActions( actions.SetFields("v1", actions.SetFields("user", actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc), + actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc), ), ), ) @@ -304,6 +306,7 @@ func (l *Login) runPreCreationActions( actions.SetFields("v1", actions.SetFields("user", actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc), + actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc), ), ), )