From e41b70b1e3bf674081dffb07635abe272a423e44 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 19 Feb 2026 06:05:19 +0100 Subject: [PATCH] feat(api): allow specifying access token type for user of type machine (#11599) # Which Problems Are Solved The user service v2 didn't allow the specify or change the access token type for users of type machine and referred to using the management API, which in return was already deprecated and linked to the user service. # How the Problems Are Solved Added a possibility to specify the access token type in the creation and update request. # Additional Changes None # Additional Context - closes #10850 - requires backport to v4.x --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../user/v2/integration_test/user_test.go | 51 ++++++++++++++++++ internal/api/grpc/user/v2/machine.go | 27 ++++++++-- internal/api/grpc/user/v2/machine_test.go | 15 +++--- internal/command/user_v2_machine.go | 17 ++++-- internal/command/user_v2_machine_test.go | 54 +++++++++++++++++++ proto/zitadel/user/v2/user_service.proto | 30 ++++++++++- 6 files changed, 176 insertions(+), 18 deletions(-) diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 12b18daf0..9d586383b 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -3639,6 +3639,28 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { }, } }, + }, { + name: "machine access token type jwt", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType()) + }, + } + }, }} for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -4308,6 +4330,35 @@ func TestServer_UpdateUser_And_Compare(t *testing.T) { }, } }, + }, { + name: "machine accessTokenType", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: OrgCTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "Donald", + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + AccessTokenType: gu.Ptr(user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT), + }, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType()) + }, + } + }, }} for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go index 7889d2c6d..a3823058e 100644 --- a/internal/api/grpc/user/v2/machine.go +++ b/internal/api/grpc/user/v2/machine.go @@ -17,7 +17,7 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea Username: userName, Name: machinePb.Name, Description: machinePb.GetDescription(), - AccessTokenType: domain.OIDCTokenTypeBearer, + AccessTokenType: accessTokenTypeToDomain(machinePb.GetAccessTokenType()), ObjectRoot: models.ObjectRoot{ ResourceOwner: orgId, AggregateID: userId, @@ -51,10 +51,27 @@ func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.Upda } func updateMachineUserToCommand(userId string, userName *string, machine *user.UpdateUserRequest_Machine) *command.ChangeMachine { + var accessTokenType *domain.OIDCTokenType + if machine.AccessTokenType != nil { + tokenType := accessTokenTypeToDomain(*machine.AccessTokenType) + accessTokenType = &tokenType + } return &command.ChangeMachine{ - ID: userId, - Username: userName, - Name: machine.Name, - Description: machine.Description, + ID: userId, + Username: userName, + Name: machine.Name, + Description: machine.Description, + AccessTokenType: accessTokenType, + } +} + +func accessTokenTypeToDomain(accessTokenType user.AccessTokenType) domain.OIDCTokenType { + switch accessTokenType { + case user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER: + return domain.OIDCTokenTypeBearer + case user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT: + return domain.OIDCTokenTypeJWT + default: + return domain.OIDCTokenTypeBearer } } diff --git a/internal/api/grpc/user/v2/machine_test.go b/internal/api/grpc/user/v2/machine_test.go index 96d77d8fa..a5dc710b1 100644 --- a/internal/api/grpc/user/v2/machine_test.go +++ b/internal/api/grpc/user/v2/machine_test.go @@ -9,6 +9,7 @@ import ( "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) @@ -40,15 +41,17 @@ func Test_patchMachineUserToCommand(t *testing.T) { userId: "userId", userName: gu.Ptr("userName"), machine: &user.UpdateUserRequest_Machine{ - Name: gu.Ptr("name"), - Description: gu.Ptr("description"), + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + AccessTokenType: gu.Ptr(user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT), }, }, want: &command.ChangeMachine{ - ID: "userId", - Username: gu.Ptr("userName"), - Name: gu.Ptr("name"), - Description: gu.Ptr("description"), + ID: "userId", + Username: gu.Ptr("userName"), + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeJWT), }, }} for _, tt := range tests { diff --git a/internal/command/user_v2_machine.go b/internal/command/user_v2_machine.go index 3de1ea9af..c391d6a3a 100644 --- a/internal/command/user_v2_machine.go +++ b/internal/command/user_v2_machine.go @@ -11,11 +11,12 @@ import ( ) type ChangeMachine struct { - ID string - ResourceOwner string - Username *string - Name *string - Description *string + ID string + ResourceOwner string + Username *string + Name *string + Description *string + AccessTokenType *domain.OIDCTokenType // Details are set after a successful execution of the command Details *domain.ObjectDetails @@ -31,6 +32,9 @@ func (h *ChangeMachine) Changed() bool { if h.Description != nil { return true } + if h.AccessTokenType != nil { + return true + } return false } @@ -64,6 +68,9 @@ func (c *Commands) ChangeUserMachine(ctx context.Context, machine *ChangeMachine if machine.Description != nil && *machine.Description != existingMachine.Description { machineChanges = append(machineChanges, user.ChangeDescription(*machine.Description)) } + if machine.AccessTokenType != nil && *machine.AccessTokenType != existingMachine.AccessTokenType { + machineChanges = append(machineChanges, user.ChangeAccessTokenType(*machine.AccessTokenType)) + } if len(machineChanges) > 0 { cmds = append(cmds, user.NewMachineChangedEvent(ctx, &existingMachine.Aggregate().Aggregate, machineChanges)) } diff --git a/internal/command/user_v2_machine_test.go b/internal/command/user_v2_machine_test.go index c5883286b..831ebdd3d 100644 --- a/internal/command/user_v2_machine_test.go +++ b/internal/command/user_v2_machine_test.go @@ -284,6 +284,60 @@ func TestCommandSide_ChangeUserMachine(t *testing.T) { }, }, }, + { + name: "change machine accessTokenType, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectPush( + user.NewMachineChangedEvent(context.Background(), + &userAgg.Aggregate, + []user.MachineChanges{ + user.ChangeAccessTokenType(domain.OIDCTokenTypeJWT), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeJWT), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine accessTokenType, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index f8c33646d..87c181713 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2112,6 +2112,19 @@ message CreateUserRequest{ example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; } ]; + // The access token type defines the type of access token that is generated for the user. + // By default, the access token type is an opaque Bearer token. You can change it to JWT, + // which will include more information about the user and their permissions in the token itself. + // This can be useful for machine users that need to call services that require more information about the user, + // without having to call the userinfo or introspection endpoint. + // However, revoked JWTs may still be considered valid until they expire. + // Always call the userinfo or introspection endpoint to verify that a JWT is still valid. + AccessTokenType access_token_type = 3 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ACCESS_TOKEN_TYPE_BEARER\""; + } + ]; } // The unique identifier of the organization the user belongs to. string organization_id = 1 [ @@ -2160,8 +2173,8 @@ message CreateUserRequest{ Human human = 4; // Users of type machine are users that are meant to be used by a machine. // In order to authenticate, [add a secret](/docs/reference/api/user/zitadel.user.v2.UserService.AddSecret), [a key](/docs/reference/api/user/zitadel.user.v2.UserService.AddKey) or [a personal access token](/docs/reference/api/user/zitadel.user.v2.UserService.AddPersonalAccessToken) to the user. - // Tokens generated for new users of type machine will be of an opaque Bearer type. - // You can change the users token type to JWT by using the management v1 service method UpdateMachine. + // By default, tokens generated for new users of type machine will be of an opaque Bearer type. + // You can change it to JWT by specifying the access token type in the machine field. Machine machine = 5; } option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { @@ -2543,6 +2556,19 @@ message UpdateUserRequest{ example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; } ]; + // The access token type defines the type of access token that is generated for the user. + // By default, the access token type is an opaque Bearer token. You can change it to JWT, + // which will include more information about the user and their permissions in the token itself. + // This can be useful for machine users that need to call services that require more information about the user, + // without having to call the userinfo or introspection endpoint. + // However, revoked JWT tokens might still be considered valid until they expire. + // Therefore, you must call the userinfo or introspection endpoint to check whether they are still valid. + optional AccessTokenType access_token_type = 3 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ACCESS_TOKEN_TYPE_BEARER\""; + } + ]; } // The user id is the users unique identifier in the instance. // It can't be changed.