Files
zitadel/internal/command/project_application_oidc_dcr_test.go
T
jmaretteandGitHub 9070b537d2 feat(oidc): add RFC 7592 dynamic client management (#12315)
# Which Problems Are Solved

#12313 lets clients register themselves as OIDC applications (RFC 7591),
but a dynamically registered client has no way to manage itself
afterwards:

- The registration response returns no `registration_access_token` and
no `registration_client_uri`, which RFC 7591 and 7592 clients (including
the MCP SDKs) expect in order to manage their own registration.
- There is no way to read back, update or delete a registration without
an operator acting on the client's behalf through the Console or the
Management API. A client that needs to rotate a redirect URI has to ask
a human.

# How the Problems Are Solved

Implement the [OAuth 2.0 Dynamic Client Registration Management Protocol
(RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592): `GET`, `PUT`
and `DELETE` on `/oauth/v2/register/{client_id}`, wired through the same
`op.WithSetRouter` hook as the registration endpoint.

## Control plane: the same security settings, no new switch

The management endpoints are gated on the
`DynamicClientRegistrationSettings.enabled` introduced in #12313. No new
setting, no feature flag: a client that may register may manage what it
registered. When registration is disabled the endpoints answer `404`,
exactly as `POST /oauth/v2/register` does.

`allow_unauthenticated` selects the registration mode and does not apply
here, because the registration access token authorizes these endpoints
in both modes.

## Authorization: the registration access token, and nothing else

RFC 7592 §3 defines the credential as the registration access token
issued at registration and bound to a single client, not a user
credential. So a client manages only itself, proving it with the token
it was handed:

| | Registration (`POST`) | Management (`GET` / `PUT` / `DELETE`) |
| --- | --- | --- |
| Credential | Access token, or none in open mode | Registration access
token |
| Permission | `project.app.register_dynamic` in token mode | None |

Neither `project.app.register_dynamic`, which governs who may *create*
clients, nor `project.app.write` applies to the management endpoints.
One authorization path per endpoint is easier to reason about than two,
and there is no user identity to check against in the first place.
Operators keep managing dynamically registered clients through the
Management API and the Console, like any other application; they cannot
obtain a client's registration access token, by design.

Status codes: a missing, malformed or non matching token is `401`; a
client that no longer exists is `404`. The token's `client_id` is
checked against the path before any lookup, so a token bound to another
client is rejected without touching the database.

## The token itself

- **Stored like a client secret.** Only its passwap hash is persisted,
through a new
`project.application.config.oidc.registration_token.changed` event; the
plain token leaves the server exactly once, in the response that issues
it.
- **Bound to its client and organization** through authenticated
encryption with the instance key, the same mechanism as refresh tokens.
The opaque token handed to the client encrypts
`client_id:org_id:secret`, so the endpoint recovers the binding before
checking the secret against the stored hash.
- **Verified in O(1)** against the hash on the projection, folded into
the client lookup the handler already does, with a strongly consistent
fallback to the eventstore so a token that was just rotated is accepted
before it is projected.
- **Rotated on update** (RFC 7592 §4.4) and invalidated implicitly when
the client is deleted. A read does not rotate it. Every update rotates,
including one that changes no metadata, so a client always gets a usable
token back.

## Reusing the existing persistence

A small refactor extracts the shared OIDC application creation and
update logic (`pushOIDCApplication` gains a variadic `extraEvents`, and
`oidcApplicationChangeEvent` is lifted out of `UpdateOIDCApplication`),
so the dynamic commands reuse it without the permission check. The
registration token event is pushed atomically with the application, so a
registered client is never left unmanageable.

# Additional Changes

- Setup step **74** adds the `registration_token` column to the
`apps7_oidc_configs` projection, with a reducer for the new event
modelled on the client secret. Applications are not part of the
relational storage projection, so there is no second write path to keep
in sync.
- The registration response (`POST`) now also carries
`registration_access_token` and `registration_client_uri`.
- Docs: a "Manage a registration" section in the dynamic client
registration guide covering the three operations, the authorization
model, token rotation and the new limitations, plus the management
endpoints on the OIDC endpoints page.
- Unit tests for the command layer (issue, verify, rotate, update,
delete), the projection reducer, and the token binding and response
mapping. An integration test covers the full lifecycle (register, read,
update with rotation, delete, read again with `404`), a token bound to
another client, and the endpoints not being served when registration is
disabled. None of the integration calls carries a user access token,
which pins down that the management endpoints require no permission of
their own.

# Additional Context

- Part of the MCP authorization effort in #9810. Follow-up to #12313
(RFC 7591), which is now merged; this branch has been rebased on `main`
and carries the settings and authorization model agreed there.
- **Open question for the review**: the authorization model above
(registration access token only, no operator path through these
endpoints) is the one I proposed when #12313 was reviewed, but it was
never explicitly confirmed. If you would rather have `project.app.write`
also grant access to a client in the operator's own organization, that
is a contained change in `authorizeClientManagement`.
- Rotation is eventually consistent: right after an update the previous
token may keep working for a brief moment until the rotation is
projected, while the new token works immediately through the eventstore
fallback. This matches how ZITADEL validates access tokens, where
revocation is likewise eventually consistent. It is documented in the
guide and asserted as such in the integration test.
- A database migration is introduced for the new projection column. No
proto change and no new feature flag.
- Verified locally: full build, `go vet` including `-tags integration`,
`golangci-lint` with 0 issues, and the unit suites for every touched
package. As before, I cannot run the integration suite here, so CI
remains the check on it.
2026-08-03 08:37:59 +00:00

663 lines
22 KiB
Go

package command
import (
"context"
"net"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommandSide_EnsureDCRProject(t *testing.T) {
t.Parallel()
dcrProjectAdded := func(projectID string) eventstore.Event {
return eventFromEventPusher(project.NewProjectAddedEvent(
context.Background(),
&project.NewAggregate(projectID, "org1").Aggregate,
DCRProjectName,
false,
false,
false,
domain.PrivateLabelingSettingUnspecified,
))
}
t.Run("missing resource owner, invalid argument", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore()(t),
checkPermission: newMockPermissionCheckNotAllowed(),
}
_, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "")
assert.True(t, zerrors.IsErrorInvalidArgument(err))
})
t.Run("existing project resolved from the eventstore", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(
expectFilter(dcrProjectAdded("existing")),
)(t),
checkPermission: newMockPermissionCheckNotAllowed(),
}
projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
assert.NoError(t, err)
assert.Equal(t, "existing", projectID)
})
t.Run("project created when none exists, without permission check", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(
expectFilter(), // lookup: no DCR project yet
expectFilter(), // project write model for the new id
expectPush(
project.NewProjectAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
DCRProjectName,
false,
false,
false,
domain.PrivateLabelingSettingUnspecified,
),
),
)(t),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"),
// Provisioning the DCR project must not depend on project.create.
checkPermission: newMockPermissionCheckNotAllowed(),
}
c.setMilestonesCompletedForTest("instanceID")
projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
assert.NoError(t, err)
assert.Equal(t, "project1", projectID)
})
t.Run("concurrent creation resolves the winner from the eventstore", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(
expectFilter(), // lookup: no DCR project yet
expectFilter(), // project write model for the new id
expectPushFailed(
zerrors.ThrowAlreadyExists(nil, "id", "project name already taken"),
project.NewProjectAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
DCRProjectName,
false,
false,
false,
domain.PrivateLabelingSettingUnspecified,
),
),
expectFilter(dcrProjectAdded("winner")), // re-resolve: the racing winner
)(t),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"),
checkPermission: newMockPermissionCheckNotAllowed(),
}
c.setMilestonesCompletedForTest("instanceID")
projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
assert.NoError(t, err)
assert.Equal(t, "winner", projectID)
})
}
func TestCommandSide_AddDynamicOIDCClient(t *testing.T) {
t.Parallel()
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
ctx context.Context
projectID string
resourceOwner string
oidcApp *domain.OIDCApp
}
type res struct {
want *domain.OIDCApp
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing project id, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
projectID: "",
resourceOwner: "org1",
oidcApp: &domain.OIDCApp{AppName: "app"},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "invalid app, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
projectID: "project1",
resourceOwner: "org1",
oidcApp: &domain.OIDCApp{AppName: ""},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "project not existing, precondition failed error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
projectID: "project1",
resourceOwner: "org1",
oidcApp: &domain.OIDCApp{
AppName: "app",
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
},
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
{
name: "confidential client (client_secret_basic), secret returned, no permission required",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
expectFilter(),
expectPush(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app (app1)",
),
project.NewOIDCConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
domain.OIDCVersionV1,
"app1",
"client1",
"secret",
[]string{"https://client.example.com/callback"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb,
domain.OIDCAuthMethodTypeBasic,
[]string{},
false,
domain.OIDCTokenTypeBearer,
false,
false,
false,
0,
[]string{},
false,
"",
domain.LoginVersionUnspecified,
"",
),
// The registration access token (RFC 7592 §3) is persisted in the same
// push as the application, so a registered client is never left
// unmanageable.
project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"secret",
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
projectID: "project1",
resourceOwner: "org1",
oidcApp: &domain.OIDCApp{
AppName: "app",
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
},
},
res: res{
want: &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app (app1)",
ClientID: "client1",
ClientSecretString: "secret",
RegistrationAccessToken: "secret",
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
DevMode: gu.Ptr(false),
AccessTokenRoleAssertion: gu.Ptr(false),
IDTokenRoleAssertion: gu.Ptr(false),
IDTokenUserinfoAssertion: gu.Ptr(false),
ClockSkew: gu.Ptr(time.Duration(0)),
SkipNativeAppSuccessPage: gu.Ptr(false),
BackChannelLogoutURI: gu.Ptr(""),
LoginVersion: gu.Ptr(domain.LoginVersionUnspecified),
LoginBaseURI: gu.Ptr(""),
State: domain.AppStateActive,
Compliance: &domain.Compliance{},
},
},
},
{
name: "public client (none), no secret returned, no permission required",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
expectFilter(),
expectPush(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"DCR Client app1",
),
project.NewOIDCConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
domain.OIDCVersionV1,
"app1",
"client1",
"",
[]string{"https://client.example.com/callback"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb,
domain.OIDCAuthMethodTypeNone,
[]string{},
false,
domain.OIDCTokenTypeBearer,
false,
false,
false,
0,
[]string{},
false,
"",
domain.LoginVersionUnspecified,
"",
),
project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"secret",
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
projectID: "project1",
resourceOwner: "org1",
oidcApp: &domain.OIDCApp{
AppName: "",
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
},
},
res: res{
want: &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "DCR Client app1",
ClientID: "client1",
RegistrationAccessToken: "secret",
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
DevMode: gu.Ptr(false),
AccessTokenRoleAssertion: gu.Ptr(false),
IDTokenRoleAssertion: gu.Ptr(false),
IDTokenUserinfoAssertion: gu.Ptr(false),
ClockSkew: gu.Ptr(time.Duration(0)),
SkipNativeAppSuccessPage: gu.Ptr(false),
BackChannelLogoutURI: gu.Ptr(""),
LoginVersion: gu.Ptr(domain.LoginVersionUnspecified),
LoginBaseURI: gu.Ptr(""),
State: domain.AppStateActive,
Compliance: &domain.Compliance{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
newHashedSecret: mockHashedSecret("secret"),
defaultSecretGenerators: &SecretGenerators{
ClientSecret: emptyConfig,
},
// Dynamic client registration must NOT depend on app.write: deny all
// permissions and assert the client is still created.
checkPermission: newMockPermissionCheckNotAllowed(),
ipLookupFunction: func(_ string) ([]net.IP, error) {
return []net.IP{net.ParseIP("1.2.3.4")}, nil
},
}
c.setMilestonesCompletedForTest("instanceID")
got, err := c.AddDynamicOIDCClient(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner, tt.args.oidcApp)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}
func Test_dcrProjectWriteModel_Reduce(t *testing.T) {
t.Parallel()
aggregate := func(projectID string) *eventstore.Aggregate {
return &project.NewAggregate(projectID, "org1").Aggregate
}
// The write model type-switches on the repository events, so feed it the typed events
// directly rather than the raw ones eventFromEventPusher builds for the mock eventstore.
added := func(projectID, name string) eventstore.Event {
return project.NewProjectAddedEvent(
context.Background(), aggregate(projectID), name,
false, false, false, domain.PrivateLabelingSettingUnspecified,
)
}
renamed := func(projectID, oldName, newName string) eventstore.Event {
return project.NewProjectChangeEvent(
context.Background(), aggregate(projectID), oldName,
[]project.ProjectChanges{project.ChangeName(newName)},
)
}
removed := func(projectID string) eventstore.Event {
return project.NewProjectRemovedEvent(
context.Background(), aggregate(projectID), DCRProjectName, nil,
)
}
tests := []struct {
name string
events []eventstore.Event
want string
}{
{
name: "no events, no project",
events: nil,
want: "",
},
{
name: "dcr project added",
events: []eventstore.Event{added("p1", DCRProjectName)},
want: "p1",
},
{
name: "unrelated project ignored",
events: []eventstore.Event{added("p1", "Some Other Project")},
want: "",
},
{
// Without reducing ProjectChanged the write model would still return p1, while
// the name-based projection query no longer finds it.
name: "renamed away from the dcr name is dropped",
events: []eventstore.Event{added("p1", DCRProjectName), renamed("p1", DCRProjectName, "Renamed")},
want: "",
},
{
name: "renamed onto the dcr name is picked up",
events: []eventstore.Event{added("p1", "Some Other Project"), renamed("p1", "Some Other Project", DCRProjectName)},
want: "p1",
},
{
name: "rename of an unrelated project does not clear the dcr project",
events: []eventstore.Event{
added("p1", DCRProjectName),
added("p2", "Some Other Project"),
renamed("p2", "Some Other Project", "Still Something Else"),
},
want: "p1",
},
{
name: "removed dcr project is dropped",
events: []eventstore.Event{added("p1", DCRProjectName), removed("p1")},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
wm := newDCRProjectWriteModel("org1")
wm.AppendEvents(tt.events...)
require.NoError(t, wm.Reduce())
assert.Equal(t, tt.want, wm.projectID)
})
}
}
func TestCommandSide_VerifyDynamicClientRegistrationToken(t *testing.T) {
t.Parallel()
regToken := func() *project.OIDCConfigRegistrationTokenChangedEvent {
return project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate, "app1", "$plain$$secret")
}
tests := []struct {
name string
eventstore func(*testing.T) *eventstore.Eventstore
secret string
wantErr func(error) bool
}{
{
name: "valid token",
eventstore: expectEventstore(expectFilter(eventFromEventPusher(regToken()))),
secret: "secret",
wantErr: nil,
},
{
name: "wrong secret is unauthenticated",
eventstore: expectEventstore(expectFilter(eventFromEventPusher(regToken()))),
secret: "wrong",
wantErr: zerrors.IsUnauthenticated,
},
{
name: "no token set is unauthenticated",
eventstore: expectEventstore(expectFilter()),
secret: "secret",
wantErr: zerrors.IsUnauthenticated,
},
{
name: "empty secret is unauthenticated",
eventstore: expectEventstore(),
secret: "",
wantErr: zerrors.IsUnauthenticated,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: tt.eventstore(t),
secretHasher: mockPasswordHasher(""),
}
err := c.VerifyDynamicClientRegistrationToken(context.Background(), "project1", "app1", "org1", tt.secret)
if tt.wantErr == nil {
assert.NoError(t, err)
return
}
assert.True(t, tt.wantErr(err), "got wrong err: %v", err)
})
}
}
func TestCommandSide_UpdateDynamicOIDCClient(t *testing.T) {
t.Parallel()
existingClient := func() []eventstore.Event {
return []eventstore.Event{
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate, "app1", "DCR Client app1")),
eventFromEventPusher(project.NewOIDCConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
domain.OIDCVersionV1, "app1", "client1", "",
[]string{"https://client.example.com/callback"},
[]domain.OIDCResponseType{domain.OIDCResponseTypeCode},
[]domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
domain.OIDCApplicationTypeWeb, domain.OIDCAuthMethodTypeNone,
[]string{}, false, domain.OIDCTokenTypeBearer,
false, false, false, 0, []string{}, false, "",
domain.LoginVersionUnspecified, "")),
}
}
sameMetadata := &domain.OIDCApp{
ObjectRoot: models.ObjectRoot{AggregateID: "project1"},
AppID: "app1",
RedirectUris: []string{"https://client.example.com/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
}
t.Run("unchanged metadata still rotates the token, no permission required", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(
// A single filter: getOIDCAppWriteModel already reduces the write model.
expectFilter(existingClient()...),
expectPush(
project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate, "app1", "secret"),
),
)(t),
newHashedSecret: mockHashedSecret("secret"),
checkPermission: newMockPermissionCheckNotAllowed(),
}
got, err := c.UpdateDynamicOIDCClient(context.Background(), sameMetadata, "org1")
assert.NoError(t, err)
assert.Equal(t, "client1", got.ClientID)
assert.Equal(t, "secret", got.RegistrationAccessToken)
})
t.Run("not existing client is not found", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(expectFilter())(t),
checkPermission: newMockPermissionCheckNotAllowed(),
}
_, err := c.UpdateDynamicOIDCClient(context.Background(), sameMetadata, "org1")
assert.True(t, zerrors.IsNotFound(err), "got wrong err: %v", err)
})
}
func TestCommandSide_RemoveDynamicOIDCClient(t *testing.T) {
t.Parallel()
t.Run("remove existing client, no permission required", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate, "app1", "DCR Client app1")),
),
expectPush(
project.NewApplicationRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate, "app1", "DCR Client app1", ""),
),
)(t),
checkPermission: newMockPermissionCheckNotAllowed(),
}
got, err := c.RemoveDynamicOIDCClient(context.Background(), "project1", "app1", "org1")
assert.NoError(t, err)
assertObjectDetails(t, &domain.ObjectDetails{ResourceOwner: "org1"}, got)
})
t.Run("not existing client is not found", func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: expectEventstore(expectFilter())(t),
checkPermission: newMockPermissionCheckNotAllowed(),
}
_, err := c.RemoveDynamicOIDCClient(context.Background(), "project1", "app1", "org1")
assert.True(t, zerrors.IsNotFound(err), "got wrong err: %v", err)
})
}