fix(actions): allow adding raw metadata values via appendMetadataRaw (#12567)

# 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 <noreply@anthropic.com>
This commit is contained in:
Livio Spring
2026-08-11 15:44:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ba3a45bee4
commit 2bd42e8fc4
5 changed files with 200 additions and 4 deletions
@@ -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
@@ -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
+47
View File
@@ -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)
+126
View File
@@ -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))
}
+3
View File
@@ -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),
),
),
)