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 <briancarlson6174@gmail.com>
Co-authored-by: Warp <agent@warp.dev>

---------

Shoutout: [TheRobotCarlson](https://github.com/TheRobotCarlson)
Shoutout: [ostempel](https://github.com/ostempel)
This commit is contained in:
Wim Van Laer
2026-02-05 16:05:02 +01:00
committed by GitHub
co-authored by [TheRobotCarlson](https://github.com/TheRobotCarlson) Warp [agent@warp.dev](mailto:agent@warp.dev) Brian Carlson Warp
parent e77e0831a6
commit 944765f89c
8 changed files with 139 additions and 7 deletions
+1 -1
View File
@@ -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
}
@@ -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(),
@@ -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)
@@ -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
}
@@ -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(),
@@ -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)
+11 -1
View File
@@ -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)
}
@@ -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,