From 944765f89ccc2b7077c01ec3f01c6ddf8690033f Mon Sep 17 00:00:00 2001 From: Wim Van Laer Date: Thu, 5 Feb 2026 16:05:02 +0100 Subject: [PATCH] fix(api): honor request-provided IDs for OIDC apps (#11506) Problem: OIDC CreateApplication requests in both app/v2beta and application/v2 accept an ID field (id / application_id), but the OIDC creation path dropped it, always generating a new app ID/client ID. Changes: Thread the request-provided ID into the OIDC domain request in both handlers. Update the OIDC command path to use the provided AppID when present (fallback to generated ID when empty), mirroring API apps. Add integration tests covering provided IDs for OIDC CreateApplication on v2beta and v2. Tests: go test -count=1 -tags integration ./internal/api/grpc/app/v2beta/integration_test -run TestCreateOIDCApplication_WithProvidedID go test -count=1 -tags integration ./internal/api/grpc/application/v2/integration_test -run TestCreateOIDCApplication_WithProvidedID Co-Authored-By: [TheRobotCarlson](https://github.com/TheRobotCarlson) Co-Authored-By: Warp [agent@warp.dev](mailto:agent@warp.dev) --------- Co-authored-by: Brian Carlson Co-authored-by: Warp --------- Shoutout: [TheRobotCarlson](https://github.com/TheRobotCarlson) Shoutout: [ostempel](https://github.com/ostempel) --- internal/api/grpc/app/v2beta/app.go | 2 +- .../api/grpc/app/v2beta/convert/oidc_app.go | 3 +- .../grpc/app/v2beta/convert/oidc_app_test.go | 6 +- .../api/grpc/application/v2/application.go | 2 +- .../grpc/application/v2/convert/oidc_app.go | 3 +- .../application/v2/convert/oidc_app_test.go | 6 +- internal/command/project_application_oidc.go | 12 +- .../command/project_application_oidc_test.go | 112 ++++++++++++++++++ 8 files changed, 139 insertions(+), 7 deletions(-) diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go index e751bf503..4c5a5a2d3 100644 --- a/internal/api/grpc/app/v2beta/app.go +++ b/internal/api/grpc/app/v2beta/app.go @@ -34,7 +34,7 @@ func (s *Server) CreateApplication(ctx context.Context, req *connect.Request[app }), nil case *app.CreateApplicationRequest_OidcRequest: - oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetOidcRequest()) + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetId(), req.Msg.GetProjectId(), req.Msg.GetOidcRequest()) if err != nil { return nil, err } diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app.go b/internal/api/grpc/app/v2beta/convert/oidc_app.go index 223e43d16..f21b5a22f 100644 --- a/internal/api/grpc/app/v2beta/convert/oidc_app.go +++ b/internal/api/grpc/app/v2beta/convert/oidc_app.go @@ -10,7 +10,7 @@ import ( app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) -func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { +func CreateOIDCAppRequestToDomain(name, appID, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) if err != nil { return nil, err @@ -19,6 +19,7 @@ func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApp ObjectRoot: models.ObjectRoot{ AggregateID: projectID, }, + AppID: appID, AppName: name, OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: req.GetRedirectUris(), diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go index a6b3f0b70..3e8266196 100644 --- a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go +++ b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go @@ -21,6 +21,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { tt := []struct { testName string projectID string + appID string req *app.CreateOIDCApplicationRequest expectedModel *domain.OIDCApp @@ -29,6 +30,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { { testName: "unparsable login version 2 URL", projectID: "pid", + appID: "aid", req: &app.CreateOIDCApplicationRequest{ LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}}, @@ -44,6 +46,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { { testName: "all fields set", projectID: "project1", + appID: "app1", req: &app.CreateOIDCApplicationRequest{ RedirectUris: []string{"https://redirect"}, ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, @@ -67,6 +70,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { expectedModel: &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, AppName: "all fields set", + AppID: "app1", OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://redirect"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, @@ -94,7 +98,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { t.Parallel() // When - res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.appID, tc.projectID, tc.req) // Then assert.Equal(t, tc.expectedError, err) diff --git a/internal/api/grpc/application/v2/application.go b/internal/api/grpc/application/v2/application.go index 797504d19..dd86e45d4 100644 --- a/internal/api/grpc/application/v2/application.go +++ b/internal/api/grpc/application/v2/application.go @@ -34,7 +34,7 @@ func (s *Server) CreateApplication(ctx context.Context, req *connect.Request[app }), nil case *application.CreateApplicationRequest_OidcConfiguration: - oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetOidcConfiguration()) + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetApplicationId(), req.Msg.GetProjectId(), req.Msg.GetOidcConfiguration()) if err != nil { return nil, err } diff --git a/internal/api/grpc/application/v2/convert/oidc_app.go b/internal/api/grpc/application/v2/convert/oidc_app.go index 63a85b911..fc3765a43 100644 --- a/internal/api/grpc/application/v2/convert/oidc_app.go +++ b/internal/api/grpc/application/v2/convert/oidc_app.go @@ -10,7 +10,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/application/v2" ) -func CreateOIDCAppRequestToDomain(name, projectID string, req *application.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { +func CreateOIDCAppRequestToDomain(name, appID, projectID string, req *application.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) if err != nil { return nil, err @@ -19,6 +19,7 @@ func CreateOIDCAppRequestToDomain(name, projectID string, req *application.Creat ObjectRoot: models.ObjectRoot{ AggregateID: projectID, }, + AppID: appID, AppName: name, OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: req.GetRedirectUris(), diff --git a/internal/api/grpc/application/v2/convert/oidc_app_test.go b/internal/api/grpc/application/v2/convert/oidc_app_test.go index 508b7eda5..58ce20ed2 100644 --- a/internal/api/grpc/application/v2/convert/oidc_app_test.go +++ b/internal/api/grpc/application/v2/convert/oidc_app_test.go @@ -21,6 +21,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { tt := []struct { testName string projectID string + appID string req *application.CreateOIDCApplicationRequest expectedModel *domain.OIDCApp @@ -29,6 +30,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { { testName: "unparsable login version 2 URL", projectID: "pid", + appID: "aid", req: &application.CreateOIDCApplicationRequest{ LoginVersion: &application.LoginVersion{Version: &application.LoginVersion_LoginV2{ LoginV2: &application.LoginV2{BaseUri: gu.Ptr("%+o")}}, @@ -44,6 +46,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { { testName: "all fields set", projectID: "project1", + appID: "app1", req: &application.CreateOIDCApplicationRequest{ RedirectUris: []string{"https://redirect"}, ResponseTypes: []application.OIDCResponseType{application.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, @@ -67,6 +70,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { expectedModel: &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, AppName: "all fields set", + AppID: "app1", OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://redirect"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, @@ -94,7 +98,7 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { t.Parallel() // When - res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.appID, tc.projectID, tc.req) // Then assert.Equal(t, tc.expectedError, err) diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 2342ca9eb..7666f910a 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -158,10 +158,20 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } - appID, err := c.idGenerator.Next() + appID := oidcApp.AppID + if appID == "" { + appID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + existingApp, err := c.getOIDCAppWriteModel(ctx, oidcApp.AggregateID, appID, resourceOwner) if err != nil { return nil, err } + if existingApp.State != domain.AppStateUnspecified { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-lxowmp", "Errors.Project.App.AlreadyExisting") + } return c.addOIDCApplicationWithID(ctx, oidcApp, resourceOwner, appID) } diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index d728ffca4..2acb096e3 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -501,6 +501,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { ), ), expectFilter(), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -596,6 +597,116 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { }, }, }, + { + name: "create oidc app with id, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + expectFilter(), + expectPush( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app2", + "app", + ), + project.NewOIDCConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + domain.OIDCVersionV1, + "app2", + "client1", + "secret", + []string{"https://test.ch"}, + []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + domain.OIDCApplicationTypeWeb, + domain.OIDCAuthMethodTypePost, + []string{"https://test.ch/logout"}, + true, + domain.OIDCTokenTypeBearer, + true, + true, + true, + time.Second*1, + []string{"https://sub.test.ch"}, + true, + "https://test.ch/backchannel", + domain.LoginVersion2, + "https://login.test.ch", + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "client1"), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + oidcApp: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppID: "app2", + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), + AdditionalOrigins: []string{"https://sub.test.ch"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), + }, + resourceOwner: "org1", + }, + res: res{ + want: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + AppID: "app2", + AppName: "app", + ClientID: "client1", + ClientSecretString: "secret", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), + AdditionalOrigins: []string{"https://sub.test.ch"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), + State: domain.AppStateActive, + Compliance: &domain.Compliance{}, + }, + }, + }, { name: "create oidc app basic, ok", fields: fields{ @@ -609,6 +720,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { ), ), expectFilter(), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate,