mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-29 18:42:15 +00:00
# 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 <[email protected]>
501 lines
15 KiB
Go
501 lines
15 KiB
Go
package login
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/zitadel/logging"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/zitadel/zitadel/internal/actions"
|
|
"github.com/zitadel/zitadel/internal/actions/object"
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
"github.com/zitadel/zitadel/internal/idp"
|
|
"github.com/zitadel/zitadel/internal/query"
|
|
)
|
|
|
|
func (l *Login) runPostExternalAuthenticationActions(
|
|
user *domain.ExternalUser,
|
|
tokens *oidc.Tokens[*oidc.IDTokenClaims],
|
|
authRequest *domain.AuthRequest,
|
|
httpRequest *http.Request,
|
|
idpUser idp.User,
|
|
authenticationError error,
|
|
) (_ *domain.ExternalUser, userChanged bool, err error) {
|
|
ctx := httpRequest.Context()
|
|
|
|
// use the request org (scopes or domain discovery) as default
|
|
resourceOwner := authRequest.RequestedOrgID
|
|
// if the user was already linked to an IDP and redirected to that, the requested org might be empty
|
|
if resourceOwner == "" {
|
|
resourceOwner = authRequest.UserOrgID
|
|
}
|
|
// if we will have no org (e.g. user clicked directly on the IDP on the login page)
|
|
if resourceOwner == "" {
|
|
// in this case the user might nevertheless already be linked to an IDP,
|
|
// so let's do a workaround and resourceOwnerOfUserIDPLink if there would be a IDP link
|
|
resourceOwner, err = l.resourceOwnerOfUserIDPLink(ctx, authRequest.SelectedIDPConfigID, user.ExternalUserID)
|
|
logging.WithFields("authReq", authRequest.ID, "idpID", authRequest.SelectedIDPConfigID).OnError(err).
|
|
Warn("could not determine Organization ID for runPostExternalAuthenticationActions, fall back to default org id")
|
|
}
|
|
// fallback to default org id
|
|
if resourceOwner == "" {
|
|
resourceOwner = authz.GetInstance(ctx).DefaultOrganisationID()
|
|
}
|
|
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeExternalAuthentication, domain.TriggerTypePostAuthentication, resourceOwner)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
metadataList := object.MetadataListFromDomain(user.Metadatas)
|
|
apiFields := actions.WithAPIFields(
|
|
actions.SetFields("setFirstName", func(firstName string) {
|
|
user.FirstName = firstName
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setLastName", func(lastName string) {
|
|
user.LastName = lastName
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setNickName", func(nickName string) {
|
|
user.NickName = nickName
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setDisplayName", func(displayName string) {
|
|
user.DisplayName = displayName
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setPreferredLanguage", func(preferredLanguage string) {
|
|
user.PreferredLanguage = language.Make(preferredLanguage)
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setPreferredUsername", func(username string) {
|
|
user.PreferredUsername = username
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setEmail", func(email domain.EmailAddress) {
|
|
user.Email = email
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setEmailVerified", func(verified bool) {
|
|
user.IsEmailVerified = verified
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setPhone", func(phone domain.PhoneNumber) {
|
|
user.Phone = phone
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("setPhoneVerified", func(verified bool) {
|
|
user.IsPhoneVerified = verified
|
|
userChanged = true
|
|
}),
|
|
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
|
|
return metadataList.MetadataListFromDomain(c.Runtime)
|
|
}),
|
|
actions.SetFields("v1",
|
|
actions.SetFields("user",
|
|
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
|
|
actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc),
|
|
),
|
|
),
|
|
)
|
|
|
|
authErrStr := "none"
|
|
if authenticationError != nil {
|
|
authErrStr = authenticationError.Error()
|
|
}
|
|
|
|
for _, a := range triggerActions {
|
|
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
|
|
|
|
ctxFieldOptions := append(tokenCtxFields(tokens),
|
|
actions.SetFields("v1",
|
|
actions.SetFields("externalUser", func(c *actions.FieldConfig) interface{} {
|
|
return object.UserFromExternalUser(c, user)
|
|
}),
|
|
actions.SetFields("providerInfo", func(c *actions.FieldConfig) interface{} {
|
|
return c.Runtime.ToValue(idpUser)
|
|
}),
|
|
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
|
|
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
|
|
actions.SetFields("authError", authErrStr),
|
|
actions.SetFields("org",
|
|
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
|
return func(goja.FunctionCall) goja.Value {
|
|
metadata, err := l.query.SearchOrgMetadata(
|
|
ctx,
|
|
false,
|
|
resourceOwner,
|
|
&query.OrgMetadataSearchQueries{},
|
|
false,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
logging.WithError(err).Info("unable to get org metadata in action")
|
|
panic(err)
|
|
}
|
|
return object.OrgMetadataListFromQuery(c, metadata)
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
ctxFields := actions.SetContextFields(ctxFieldOptions...)
|
|
|
|
err = actions.Run(
|
|
actionCtx,
|
|
ctxFields,
|
|
apiFields,
|
|
a.Script,
|
|
a.Name,
|
|
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))...,
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
user.Metadatas = object.MetadataListToDomain(metadataList)
|
|
return user, userChanged, err
|
|
}
|
|
|
|
type authMethod string
|
|
|
|
const (
|
|
authMethodPassword authMethod = "password"
|
|
authMethodOTP authMethod = "OTP"
|
|
authMethodOTPSMS authMethod = "OTP SMS"
|
|
authMethodOTPEmail authMethod = "OTP Email"
|
|
authMethodRecoveryCode authMethod = "recovery code"
|
|
authMethodU2F authMethod = "U2F"
|
|
authMethodPasswordless authMethod = "passwordless"
|
|
)
|
|
|
|
func (l *Login) runPostInternalAuthenticationActions(
|
|
authRequest *domain.AuthRequest,
|
|
httpRequest *http.Request,
|
|
authMethod authMethod,
|
|
authenticationError error,
|
|
) ([]*domain.Metadata, error) {
|
|
ctx := httpRequest.Context()
|
|
|
|
resourceOwner := authRequest.RequestedOrgID
|
|
if resourceOwner == "" {
|
|
resourceOwner = authRequest.UserOrgID
|
|
}
|
|
|
|
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeInternalAuthentication, domain.TriggerTypePostAuthentication, resourceOwner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metadataList := object.MetadataListFromDomain(nil)
|
|
apiFields := actions.WithAPIFields(
|
|
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
|
|
return metadataList.MetadataListFromDomain(c.Runtime)
|
|
}),
|
|
actions.SetFields("v1",
|
|
actions.SetFields("user",
|
|
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
|
|
actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc),
|
|
),
|
|
),
|
|
)
|
|
for _, a := range triggerActions {
|
|
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
|
|
|
|
authErrStr := "none"
|
|
if authenticationError != nil {
|
|
authErrStr = authenticationError.Error()
|
|
}
|
|
ctxFields := actions.SetContextFields(
|
|
actions.SetFields("v1",
|
|
actions.SetFields("authMethod", authMethod),
|
|
actions.SetFields("authError", authErrStr),
|
|
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
|
|
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
|
|
),
|
|
)
|
|
|
|
err = actions.Run(
|
|
actionCtx,
|
|
ctxFields,
|
|
apiFields,
|
|
a.Script,
|
|
a.Name,
|
|
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))...,
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return object.MetadataListToDomain(metadataList), err
|
|
}
|
|
|
|
func (l *Login) runPreCreationActions(
|
|
authRequest *domain.AuthRequest,
|
|
httpRequest *http.Request,
|
|
user *domain.Human,
|
|
metadata []*domain.Metadata,
|
|
resourceOwner string,
|
|
flowType domain.FlowType,
|
|
) (*domain.Human, []*domain.Metadata, error) {
|
|
ctx := httpRequest.Context()
|
|
|
|
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, flowType, domain.TriggerTypePreCreation, resourceOwner)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
metadataList := object.MetadataListFromDomain(metadata)
|
|
apiFields := actions.WithAPIFields(
|
|
actions.SetFields("setFirstName", func(firstName string) {
|
|
user.FirstName = firstName
|
|
}),
|
|
actions.SetFields("setLastName", func(lastName string) {
|
|
user.LastName = lastName
|
|
}),
|
|
actions.SetFields("setNickName", func(nickName string) {
|
|
user.NickName = nickName
|
|
}),
|
|
actions.SetFields("setDisplayName", func(displayName string) {
|
|
user.DisplayName = displayName
|
|
}),
|
|
actions.SetFields("setPreferredLanguage", func(preferredLanguage string) {
|
|
user.PreferredLanguage = language.Make(preferredLanguage)
|
|
}),
|
|
actions.SetFields("setGender", func(gender domain.Gender) {
|
|
user.Gender = gender
|
|
}),
|
|
actions.SetFields("setUsername", func(username string) {
|
|
user.Username = username
|
|
}),
|
|
actions.SetFields("setEmail", func(email domain.EmailAddress) {
|
|
if user.Email == nil {
|
|
user.Email = &domain.Email{}
|
|
}
|
|
user.Email.EmailAddress = email
|
|
}),
|
|
actions.SetFields("setEmailVerified", func(verified bool) {
|
|
if user.Email == nil {
|
|
return
|
|
}
|
|
user.Email.IsEmailVerified = verified
|
|
}),
|
|
actions.SetFields("setPhone", func(phone domain.PhoneNumber) {
|
|
if user.Phone == nil {
|
|
user.Phone = &domain.Phone{}
|
|
}
|
|
user.Phone.PhoneNumber = phone
|
|
}),
|
|
actions.SetFields("setPhoneVerified", func(verified bool) {
|
|
if user.Phone == nil {
|
|
return
|
|
}
|
|
user.Phone.IsPhoneVerified = verified
|
|
}),
|
|
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
|
|
return metadataList.MetadataListFromDomain(c.Runtime)
|
|
}),
|
|
actions.SetFields("v1",
|
|
actions.SetFields("user",
|
|
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
|
|
actions.SetFields("appendMetadataRaw", metadataList.AppendMetadataRawFunc),
|
|
),
|
|
),
|
|
)
|
|
|
|
for _, a := range triggerActions {
|
|
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
|
|
|
|
ctxOpts := actions.SetContextFields(
|
|
actions.SetFields("v1",
|
|
actions.SetFields("user", func(c *actions.FieldConfig) interface{} {
|
|
return object.UserFromHuman(c, user)
|
|
}),
|
|
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
|
|
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
|
|
actions.SetFields("org",
|
|
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
|
return func(goja.FunctionCall) goja.Value {
|
|
metadata, err := l.query.SearchOrgMetadata(
|
|
ctx,
|
|
false,
|
|
resourceOwner,
|
|
&query.OrgMetadataSearchQueries{},
|
|
false,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
logging.WithError(err).Info("unable to get org metadata in action")
|
|
panic(err)
|
|
}
|
|
return object.OrgMetadataListFromQuery(c, metadata)
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
err = actions.Run(
|
|
actionCtx,
|
|
ctxOpts,
|
|
apiFields,
|
|
a.Script,
|
|
a.Name,
|
|
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))...,
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
return user, object.MetadataListToDomain(metadataList), err
|
|
}
|
|
|
|
func (l *Login) runPostCreationActions(
|
|
userID string,
|
|
authRequest *domain.AuthRequest,
|
|
httpRequest *http.Request,
|
|
resourceOwner string,
|
|
flowType domain.FlowType,
|
|
) ([]*domain.UserGrant, error) {
|
|
ctx := httpRequest.Context()
|
|
|
|
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, flowType, domain.TriggerTypePostCreation, resourceOwner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mutableUserGrants := &object.UserGrants{UserGrants: make([]object.UserGrant, 0)}
|
|
|
|
apiFields := actions.WithAPIFields(
|
|
actions.SetFields("userGrants", &mutableUserGrants.UserGrants),
|
|
actions.SetFields("v1",
|
|
actions.SetFields("appendUserGrant", object.AppendGrantFunc(mutableUserGrants)),
|
|
),
|
|
)
|
|
|
|
for _, a := range triggerActions {
|
|
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
|
|
|
|
ctxFields := actions.SetContextFields(
|
|
actions.SetFields("v1",
|
|
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
|
|
return func(call goja.FunctionCall) goja.Value {
|
|
user, err := l.query.GetUserByID(actionCtx, true, userID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return object.UserFromQuery(c, user)
|
|
}
|
|
}),
|
|
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
|
|
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
|
|
actions.SetFields("org",
|
|
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
|
return func(goja.FunctionCall) goja.Value {
|
|
metadata, err := l.query.SearchOrgMetadata(
|
|
ctx,
|
|
false,
|
|
resourceOwner,
|
|
&query.OrgMetadataSearchQueries{},
|
|
false,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
logging.WithError(err).Info("unable to get org metadata in action")
|
|
panic(err)
|
|
}
|
|
return object.OrgMetadataListFromQuery(c, metadata)
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
err = actions.Run(
|
|
actionCtx,
|
|
ctxFields,
|
|
apiFields,
|
|
a.Script,
|
|
a.Name,
|
|
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx, l.httpClient), actions.WithUUID(actionCtx))...,
|
|
)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return object.UserGrantsToDomain(userID, mutableUserGrants.UserGrants), err
|
|
}
|
|
|
|
func tokenCtxFields(tokens *oidc.Tokens[*oidc.IDTokenClaims]) []actions.FieldOption {
|
|
var accessToken, idToken, refreshToken string
|
|
getClaim := func(claim string) interface{} {
|
|
return nil
|
|
}
|
|
claimsJSON := func() (string, error) {
|
|
return "", nil
|
|
}
|
|
if tokens == nil {
|
|
return []actions.FieldOption{
|
|
actions.SetFields("accessToken", accessToken),
|
|
actions.SetFields("idToken", idToken),
|
|
actions.SetFields("getClaim", getClaim),
|
|
actions.SetFields("claimsJSON", claimsJSON),
|
|
actions.SetFields("refreshToken", refreshToken),
|
|
}
|
|
}
|
|
accessToken = tokens.AccessToken
|
|
refreshToken = tokens.RefreshToken
|
|
idToken = tokens.IDToken
|
|
if tokens.IDTokenClaims != nil {
|
|
getClaim = func(claim string) interface{} {
|
|
return tokens.IDTokenClaims.Claims[claim]
|
|
}
|
|
claimsJSON = func() (string, error) {
|
|
c, err := json.Marshal(tokens.IDTokenClaims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(c), nil
|
|
}
|
|
}
|
|
return []actions.FieldOption{
|
|
actions.SetFields("accessToken", accessToken),
|
|
actions.SetFields("idToken", idToken),
|
|
actions.SetFields("getClaim", getClaim),
|
|
actions.SetFields("claimsJSON", claimsJSON),
|
|
actions.SetFields("refreshToken", refreshToken),
|
|
}
|
|
}
|
|
|
|
func (l *Login) resourceOwnerOfUserIDPLink(ctx context.Context, idpConfigID string, externalUserID string) (string, error) {
|
|
idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpConfigID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
queries := []query.SearchQuery{
|
|
idQuery, externalIDQuery,
|
|
}
|
|
links, err := l.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(links.Links) != 1 {
|
|
return "", nil
|
|
}
|
|
return links.Links[0].ResourceOwner, nil
|
|
}
|