mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved - Native iOS/Android passkeys need OS trust files on the ZITADEL domain; without them, Associated Domains / App Links verification fails. - Operators could not configure iOS Team ID + Bundle ID or Android package name + SHA-256 fingerprints on OIDC apps. - `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` were not served from application config. # How the Problems Are Solved - Add iOS/Android app-link fields on OIDC app create/update (Application API v2 + Management), with validation. - Persist and project those fields; query active app-link configs instance-wide. - Serve AASA (`webcredentials`) and Digital Asset Links (`get_login_creds`) from well-known paths, with configurable `Cache-Control` and fingerprint normalization at serve time. - Console UI to edit the fields, with links to the well-known endpoints. - Operator docs for configuration, endpoints, caching, and verification. # Additional Changes - Document on API fields that well-known responses may be HTTP-cached and platform verifiers may delay propagation. - Runtime config: `WellKnown.AppLinksCacheControlMaxAge` (default `5m`; `0` → `no-store`). # Additional Context - Closes #12497 - Implemented and reviewed as stack: - #12531 API contract - #12532 storage wiring - #12536 well-known endpoints - #12537 console - #12547 docs --------- Co-authored-by: Cursor <[email protected]>
673 lines
22 KiB
Go
673 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,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
nil),
|
|
// 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,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
nil),
|
|
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, "",
|
|
"",
|
|
"",
|
|
"",
|
|
nil)),
|
|
}
|
|
}
|
|
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)
|
|
})
|
|
}
|