feat(OIDC): add back channel logout (#8837)

# Which Problems Are Solved

Currently ZITADEL supports RP-initiated logout for clients. Back-channel
logout ensures that user sessions are terminated across all connected
applications, even if the user closes their browser or loses
connectivity providing a more secure alternative for certain use cases.

# How the Problems Are Solved

If the feature is activated and the client used for the authentication
has a back_channel_logout_uri configured, a
`session_logout.back_channel` will be registered. Once a user terminates
their session, a (notification) handler will send a SET (form POST) to
the registered uri containing a logout_token (with the user's ID and
session ID).

- A new feature "back_channel_logout" is added on system and instance
level
- A `back_channel_logout_uri` can be managed on OIDC applications
- Added a `session_logout` aggregate to register and inform about sent
`back_channel` notifications
- Added a `SecurityEventToken` channel and `Form`message type in the
notification handlers
- Added `TriggeredAtOrigin` fields to `HumanSignedOut` and
`TerminateSession` events for notification handling
- Exported various functions and types in the `oidc` package to be able
to reuse for token signing in the back_channel notifier.
- To prevent that current existing session termination events will be
handled, a setup step is added to set the `current_states` for the
`projections.notifications_back_channel_logout` to the current position

- [x] requires https://github.com/zitadel/oidc/pull/671

# Additional Changes

- Updated all OTEL dependencies to v1.29.0, since OIDC already updated
some of them to that version.
- Single Session Termination feature is correctly checked (fixed feature
mapping)

# Additional Context

- closes https://github.com/zitadel/zitadel/issues/8467
- TODO:
  - Documentation
  - UI to be done: https://github.com/zitadel/zitadel/issues/8469

---------

Co-authored-by: Hidde Wieringa <hidde@hiddewieringa.nl>
This commit is contained in:
Livio Spring
2024-10-31 15:57:17 +01:00
committed by GitHub
parent 9cf67f30b8
commit 041af26917
87 changed files with 1778 additions and 280 deletions

View File

@@ -155,6 +155,7 @@ func TestCommandSide_AddInstanceDomain(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
false,
"",
),
),
),

View File

@@ -27,6 +27,7 @@ type InstanceFeatures struct {
DebugOIDCParentError *bool
OIDCSingleV1SessionTermination *bool
DisableUserTokenEvent *bool
EnableBackChannelLogout *bool
}
func (m *InstanceFeatures) isEmpty() bool {
@@ -41,7 +42,8 @@ func (m *InstanceFeatures) isEmpty() bool {
m.WebKey == nil &&
m.DebugOIDCParentError == nil &&
m.OIDCSingleV1SessionTermination == nil &&
m.DisableUserTokenEvent == nil
m.DisableUserTokenEvent == nil &&
m.EnableBackChannelLogout == nil
}
func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) {

View File

@@ -71,6 +71,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder {
feature_v2.InstanceDebugOIDCParentErrorEventType,
feature_v2.InstanceOIDCSingleV1SessionTerminationEventType,
feature_v2.InstanceDisableUserTokenEvent,
feature_v2.InstanceEnableBackChannelLogout,
).
Builder().ResourceOwner(m.ResourceOwner)
}
@@ -116,6 +117,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an
case feature.KeyDisableUserTokenEvent:
v := value.(bool)
features.DisableUserTokenEvent = &v
case feature.KeyEnableBackChannelLogout:
v := value.(bool)
features.EnableBackChannelLogout = &v
}
}
@@ -133,5 +137,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.InstanceEnableBackChannelLogout)
return cmds
}

View File

@@ -127,6 +127,7 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str
0,
nil,
false,
"",
),
}
}
@@ -439,6 +440,7 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain
0,
nil,
false,
"",
),
),
expectFilter(

View File

@@ -0,0 +1,24 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func (c *Commands) BackChannelLogoutSent(ctx context.Context, id, oidcSessionID, instanceID string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
sessionWriteModel := NewSessionLogoutWriteModel(id, instanceID, oidcSessionID)
if err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil {
return err
}
return c.pushAppendAndReduce(
ctx,
sessionWriteModel,
sessionlogout.NewBackChannelLogoutSentEvent(ctx, sessionWriteModel.aggregate, oidcSessionID),
)
}

View File

@@ -0,0 +1,74 @@
package command
import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
)
type SessionLogoutWriteModel struct {
eventstore.WriteModel
UserID string
OIDCSessionID string
ClientID string
BackChannelLogoutURI string
BackChannelLogoutSent bool
aggregate *eventstore.Aggregate
}
func NewSessionLogoutWriteModel(id string, instanceID string, sessionID string) *SessionLogoutWriteModel {
return &SessionLogoutWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: id,
ResourceOwner: instanceID,
InstanceID: instanceID,
},
aggregate: &sessionlogout.NewAggregate(id, instanceID).Aggregate,
OIDCSessionID: sessionID,
}
}
func (wm *SessionLogoutWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *sessionlogout.BackChannelLogoutRegisteredEvent:
wm.reduceRegistered(e)
case *sessionlogout.BackChannelLogoutSentEvent:
wm.reduceSent(e)
}
}
return wm.WriteModel.Reduce()
}
func (wm *SessionLogoutWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(sessionlogout.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
sessionlogout.BackChannelLogoutRegisteredType,
sessionlogout.BackChannelLogoutSentType,
).
EventData(map[string]interface{}{
"oidc_session_id": wm.OIDCSessionID,
}).
Builder()
return query
}
func (wm *SessionLogoutWriteModel) reduceRegistered(e *sessionlogout.BackChannelLogoutRegisteredEvent) {
if wm.OIDCSessionID != e.OIDCSessionID {
return
}
wm.UserID = e.UserID
wm.ClientID = e.ClientID
wm.BackChannelLogoutURI = e.BackChannelLogoutURI
}
func (wm *SessionLogoutWriteModel) reduceSent(e *sessionlogout.BackChannelLogoutSentEvent) {
if wm.OIDCSessionID != e.OIDCSessionID {
return
}
wm.BackChannelLogoutSent = true
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/repository/authrequest"
"github.com/zitadel/zitadel/internal/repository/oidcsession"
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
@@ -133,7 +134,8 @@ func (c *Commands) CreateOIDCSessionFromAuthRequest(ctx context.Context, authReq
func (c *Commands) CreateOIDCSession(ctx context.Context,
userID,
resourceOwner,
clientID string,
clientID,
backChannelLogoutURI string,
scope,
audience []string,
authMethods []domain.UserAuthMethodType,
@@ -161,6 +163,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context,
}
cmd.AddSession(ctx, userID, resourceOwner, sessionID, clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent)
cmd.RegisterLogout(ctx, sessionID, userID, clientID, backChannelLogoutURI)
if err = cmd.AddAccessToken(ctx, scope, userID, resourceOwner, reason, actor); err != nil {
return nil, err
}
@@ -433,6 +436,26 @@ func (c *OIDCSessionEvents) SetAuthRequestFailed(ctx context.Context, authReques
c.events = append(c.events, authrequest.NewFailedEvent(ctx, authRequestAggregate, domain.OIDCErrorReasonFromError(err)))
}
func (c *OIDCSessionEvents) RegisterLogout(ctx context.Context, sessionID, userID, clientID, backChannelLogoutURI string) {
// If there's no SSO session (e.g. service accounts) we do not need to register a logout handler.
// Also, if the client did not register a backchannel_logout_uri it will not support it (https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRegistration)
if sessionID == "" || backChannelLogoutURI == "" {
return
}
if !authz.GetFeatures(ctx).EnableBackChannelLogout {
return
}
c.events = append(c.events, sessionlogout.NewBackChannelLogoutRegisteredEvent(
ctx,
&sessionlogout.NewAggregate(sessionID, authz.GetInstance(ctx).InstanceID()).Aggregate,
c.oidcSessionWriteModel.AggregateID,
userID,
clientID,
backChannelLogoutURI,
))
}
func (c *OIDCSessionEvents) AddAccessToken(ctx context.Context, scope []string, userID, resourceOwner string, reason domain.TokenReason, actor *domain.TokenActor) error {
accessTokenID, err := c.idGenerator.Next()
if err != nil {

View File

@@ -24,6 +24,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/authrequest"
"github.com/zitadel/zitadel/internal/repository/oidcsession"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
@@ -732,21 +733,22 @@ func TestCommands_CreateOIDCSession(t *testing.T) {
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
userID string
resourceOwner string
clientID string
audience []string
scope []string
authMethods []domain.UserAuthMethodType
authTime time.Time
nonce string
preferredLanguage *language.Tag
userAgent *domain.UserAgent
reason domain.TokenReason
actor *domain.TokenActor
needRefreshToken bool
sessionID string
ctx context.Context
userID string
resourceOwner string
clientID string
backChannelLogoutURI string
audience []string
scope []string
authMethods []domain.UserAuthMethodType
authTime time.Time
nonce string
preferredLanguage *language.Tag
userAgent *domain.UserAgent
reason domain.TokenReason
actor *domain.TokenActor
needRefreshToken bool
sessionID string
}
tests := []struct {
name string
@@ -763,16 +765,17 @@ func TestCommands_CreateOIDCSession(t *testing.T) {
),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
userID: "userID",
resourceOwner: "orgID",
clientID: "clientID",
audience: []string{"audience"},
scope: []string{"openid", "offline_access"},
authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
authTime: testNow,
nonce: "nonce",
preferredLanguage: &language.Afrikaans,
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
userID: "userID",
resourceOwner: "orgID",
clientID: "clientID",
backChannelLogoutURI: "backChannelLogoutURI",
audience: []string{"audience"},
scope: []string{"openid", "offline_access"},
authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
authTime: testNow,
nonce: "nonce",
preferredLanguage: &language.Afrikaans,
userAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
@@ -1236,6 +1239,308 @@ func TestCommands_CreateOIDCSession(t *testing.T) {
SessionID: "sessionID",
},
},
{
name: "with backChannelLogoutURI",
fields: fields{
eventstore: expectEventstore(
expectFilter(
user.NewHumanAddedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
expectFilter(), // token lifetime
expectPush(
oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"},
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
oidcsession.NewAccessTokenAddedEvent(context.Background(),
&oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest,
&domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
),
user.NewUserTokenV2AddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "at_accessTokenID"),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"),
defaultAccessTokenLifetime: time.Hour,
defaultRefreshTokenLifetime: 7 * 24 * time.Hour,
defaultRefreshTokenIdleLifetime: 24 * time.Hour,
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
userID: "userID",
resourceOwner: "org1",
clientID: "clientID",
backChannelLogoutURI: "backChannelLogoutURI",
audience: []string{"audience"},
scope: []string{"openid", "offline_access"},
authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
authTime: testNow,
nonce: "nonce",
preferredLanguage: &language.Afrikaans,
userAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
reason: domain.TokenReasonAuthRequest,
actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
needRefreshToken: false,
},
want: &OIDCSession{
TokenID: "V2_oidcSessionID-at_accessTokenID",
ClientID: "clientID",
UserID: "userID",
Audience: []string{"audience"},
Expiration: time.Time{}.Add(time.Hour),
Scope: []string{"openid", "offline_access"},
AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
AuthTime: testNow,
Nonce: "nonce",
PreferredLanguage: &language.Afrikaans,
UserAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
Reason: domain.TokenReasonAuthRequest,
Actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
},
},
{
name: "with backChannelLogoutURI and sessionID",
fields: fields{
eventstore: expectEventstore(
expectFilter(
user.NewHumanAddedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
expectFilter(), // token lifetime
expectPush(
oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"},
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
oidcsession.NewAccessTokenAddedEvent(context.Background(),
&oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest,
&domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
),
user.NewUserTokenV2AddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "at_accessTokenID"),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"),
defaultAccessTokenLifetime: time.Hour,
defaultRefreshTokenLifetime: 7 * 24 * time.Hour,
defaultRefreshTokenIdleLifetime: 24 * time.Hour,
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
userID: "userID",
resourceOwner: "org1",
clientID: "clientID",
backChannelLogoutURI: "backChannelLogoutURI",
audience: []string{"audience"},
scope: []string{"openid", "offline_access"},
authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
authTime: testNow,
nonce: "nonce",
preferredLanguage: &language.Afrikaans,
userAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
reason: domain.TokenReasonAuthRequest,
actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
needRefreshToken: false,
sessionID: "sessionID",
},
want: &OIDCSession{
TokenID: "V2_oidcSessionID-at_accessTokenID",
ClientID: "clientID",
UserID: "userID",
Audience: []string{"audience"},
Expiration: time.Time{}.Add(time.Hour),
Scope: []string{"openid", "offline_access"},
AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
AuthTime: testNow,
Nonce: "nonce",
PreferredLanguage: &language.Afrikaans,
UserAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
Reason: domain.TokenReasonAuthRequest,
Actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
SessionID: "sessionID",
},
},
{
name: "with backChannelLogoutURI and sessionID, backchannel logout enabled",
fields: fields{
eventstore: expectEventstore(
expectFilter(
user.NewHumanAddedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
expectFilter(), // token lifetime
expectPush(
oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"},
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
sessionlogout.NewBackChannelLogoutRegisteredEvent(context.Background(),
&sessionlogout.NewAggregate("sessionID", "instanceID").Aggregate,
"V2_oidcSessionID",
"userID",
"clientID",
"backChannelLogoutURI",
),
oidcsession.NewAccessTokenAddedEvent(context.Background(),
&oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate,
"at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest,
&domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
),
user.NewUserTokenV2AddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "at_accessTokenID"),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"),
defaultAccessTokenLifetime: time.Hour,
defaultRefreshTokenLifetime: 7 * 24 * time.Hour,
defaultRefreshTokenIdleLifetime: 24 * time.Hour,
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: authz.WithFeatures(authz.WithInstanceID(context.Background(), "instanceID"), feature.Features{EnableBackChannelLogout: true}),
userID: "userID",
resourceOwner: "org1",
clientID: "clientID",
backChannelLogoutURI: "backChannelLogoutURI",
audience: []string{"audience"},
scope: []string{"openid", "offline_access"},
authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
authTime: testNow,
nonce: "nonce",
preferredLanguage: &language.Afrikaans,
userAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
reason: domain.TokenReasonAuthRequest,
actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
needRefreshToken: false,
sessionID: "sessionID",
},
want: &OIDCSession{
TokenID: "V2_oidcSessionID-at_accessTokenID",
ClientID: "clientID",
UserID: "userID",
Audience: []string{"audience"},
Expiration: time.Time{}.Add(time.Hour),
Scope: []string{"openid", "offline_access"},
AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
AuthTime: testNow,
Nonce: "nonce",
PreferredLanguage: &language.Afrikaans,
UserAgent: &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
Reason: domain.TokenReasonAuthRequest,
Actor: &domain.TokenActor{
UserID: "user2",
Issuer: "foo.com",
},
SessionID: "sessionID",
},
},
{
name: "impersonation not allowed",
fields: fields{
@@ -1412,6 +1717,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) {
tt.args.userID,
tt.args.resourceOwner,
tt.args.clientID,
tt.args.backChannelLogoutURI,
tt.args.scope,
tt.args.audience,
tt.args.authMethods,

View File

@@ -31,6 +31,7 @@ type addOIDCApp struct {
ClockSkew time.Duration
AdditionalOrigins []string
SkipSuccessPageForNativeApp bool
BackChannelLogoutURI string
ClientID string
ClientSecret string
@@ -108,6 +109,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation {
app.ClockSkew,
trimStringSliceWhiteSpaces(app.AdditionalOrigins),
app.SkipSuccessPageForNativeApp,
app.BackChannelLogoutURI,
),
}, nil
}, nil
@@ -199,6 +201,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain
oidcApp.ClockSkew,
trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins),
oidcApp.SkipNativeAppSuccessPage,
strings.TrimSpace(oidcApp.BackChannelLogoutURI),
))
addedApplication.AppID = oidcApp.AppID
@@ -256,6 +259,7 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA
oidc.ClockSkew,
trimStringSliceWhiteSpaces(oidc.AdditionalOrigins),
oidc.SkipNativeAppSuccessPage,
strings.TrimSpace(oidc.BackChannelLogoutURI),
)
if err != nil {
return nil, err

View File

@@ -36,6 +36,7 @@ type OIDCApplicationWriteModel struct {
State domain.AppState
AdditionalOrigins []string
SkipNativeAppSuccessPage bool
BackChannelLogoutURI string
oidc bool
}
@@ -165,6 +166,7 @@ func (wm *OIDCApplicationWriteModel) appendAddOIDCEvent(e *project.OIDCConfigAdd
wm.ClockSkew = e.ClockSkew
wm.AdditionalOrigins = e.AdditionalOrigins
wm.SkipNativeAppSuccessPage = e.SkipNativeAppSuccessPage
wm.BackChannelLogoutURI = e.BackChannelLogoutURI
}
func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfigChangedEvent) {
@@ -213,6 +215,9 @@ func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfig
if e.SkipNativeAppSuccessPage != nil {
wm.SkipNativeAppSuccessPage = *e.SkipNativeAppSuccessPage
}
if e.BackChannelLogoutURI != nil {
wm.BackChannelLogoutURI = *e.BackChannelLogoutURI
}
}
func (wm *OIDCApplicationWriteModel) Query() *eventstore.SearchQueryBuilder {
@@ -254,6 +259,7 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent(
clockSkew time.Duration,
additionalOrigins []string,
skipNativeAppSuccessPage bool,
backChannelLogoutURI string,
) (*project.OIDCConfigChangedEvent, bool, error) {
changes := make([]project.OIDCConfigChanges, 0)
var err error
@@ -303,6 +309,9 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent(
if wm.SkipNativeAppSuccessPage != skipNativeAppSuccessPage {
changes = append(changes, project.ChangeSkipNativeAppSuccessPage(skipNativeAppSuccessPage))
}
if wm.BackChannelLogoutURI != backChannelLogoutURI {
changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI))
}
if len(changes) == 0 {
return nil, false, nil

View File

@@ -175,6 +175,7 @@ func TestAddOIDCApp(t *testing.T) {
0,
[]string{"https://sub.test.ch"},
false,
"",
),
},
},
@@ -240,6 +241,7 @@ func TestAddOIDCApp(t *testing.T) {
0,
nil,
false,
"",
),
},
},
@@ -305,6 +307,7 @@ func TestAddOIDCApp(t *testing.T) {
0,
nil,
false,
"",
),
},
},
@@ -370,6 +373,7 @@ func TestAddOIDCApp(t *testing.T) {
0,
nil,
false,
"",
),
},
},
@@ -516,6 +520,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
true,
"https://test.ch/backchannel",
),
),
),
@@ -543,6 +548,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{" https://sub.test.ch "},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: " https://test.ch/backchannel ",
},
resourceOwner: "org1",
},
@@ -571,6 +577,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
State: domain.AppStateActive,
Compliance: &domain.Compliance{},
},
@@ -614,6 +621,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
true,
"https://test.ch/backchannel",
),
),
),
@@ -641,6 +649,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
},
resourceOwner: "org1",
},
@@ -669,6 +678,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
State: domain.AppStateActive,
Compliance: &domain.Compliance{},
},
@@ -847,6 +857,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
true,
"https://test.ch/backchannel",
),
),
),
@@ -875,6 +886,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
},
resourceOwner: "org1",
},
@@ -916,6 +928,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
true,
"https://test.ch/backchannel",
),
),
),
@@ -944,6 +957,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{" https://sub.test.ch "},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: " https://test.ch/backchannel ",
},
resourceOwner: "org1",
},
@@ -985,6 +999,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
true,
"https://test.ch/backchannel",
),
),
),
@@ -1019,6 +1034,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 2,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
},
resourceOwner: "org1",
},
@@ -1046,6 +1062,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) {
ClockSkew: time.Second * 2,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: true,
BackChannelLogoutURI: "https://test.ch/backchannel",
Compliance: &domain.Compliance{},
State: domain.AppStateActive,
},
@@ -1170,6 +1187,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
false,
"",
),
),
),
@@ -1213,6 +1231,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) {
ClockSkew: time.Second * 1,
AdditionalOrigins: []string{"https://sub.test.ch"},
SkipNativeAppSuccessPage: false,
BackChannelLogoutURI: "",
State: domain.AppStateActive,
},
},
@@ -1327,6 +1346,7 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
false,
"",
),
),
),
@@ -1362,6 +1382,7 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
false,
"",
),
),
),
@@ -1396,6 +1417,7 @@ func TestCommands_VerifyOIDCClientSecret(t *testing.T) {
time.Second*1,
[]string{"https://sub.test.ch"},
false,
"",
),
),
),

View File

@@ -47,6 +47,7 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O
ClockSkew: writeModel.ClockSkew,
AdditionalOrigins: writeModel.AdditionalOrigins,
SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage,
BackChannelLogoutURI: writeModel.BackChannelLogoutURI,
}
}

View File

@@ -19,6 +19,7 @@ type SystemFeatures struct {
ImprovedPerformance []feature.ImprovedPerformanceType
OIDCSingleV1SessionTermination *bool
DisableUserTokenEvent *bool
EnableBackChannelLogout *bool
}
func (m *SystemFeatures) isEmpty() bool {
@@ -31,7 +32,8 @@ func (m *SystemFeatures) isEmpty() bool {
// nil check to allow unset improvements
m.ImprovedPerformance == nil &&
m.OIDCSingleV1SessionTermination == nil &&
m.DisableUserTokenEvent == nil
m.DisableUserTokenEvent == nil &&
m.EnableBackChannelLogout == nil
}
func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) {

View File

@@ -62,6 +62,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder {
feature_v2.SystemImprovedPerformanceEventType,
feature_v2.SystemOIDCSingleV1SessionTerminationEventType,
feature_v2.SystemDisableUserTokenEvent,
feature_v2.SystemEnableBackChannelLogout,
).
Builder().ResourceOwner(m.ResourceOwner)
}
@@ -100,6 +101,9 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) {
case feature.KeyDisableUserTokenEvent:
v := value.(bool)
features.DisableUserTokenEvent = &v
case feature.KeyEnableBackChannelLogout:
v := value.(bool)
features.EnableBackChannelLogout = &v
}
}
@@ -115,6 +119,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe
cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.SystemOIDCSingleV1SessionTerminationEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.SystemEnableBackChannelLogout)
return cmds
}

View File

@@ -628,16 +628,21 @@ func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, h
return addEvent
}
func (c *Commands) HumansSignOut(ctx context.Context, agentID string, userIDs []string) error {
type HumanSignOutSession struct {
ID string
UserID string
}
func (c *Commands) HumansSignOut(ctx context.Context, agentID string, sessions []HumanSignOutSession) error {
if agentID == "" {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-2M0ds", "Errors.User.UserIDMissing")
}
if len(userIDs) == 0 {
if len(sessions) == 0 {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-M0od3", "Errors.User.UserIDMissing")
}
events := make([]eventstore.Command, 0)
for _, userID := range userIDs {
existingUser, err := c.getHumanWriteModelByID(ctx, userID, "")
for _, session := range sessions {
existingUser, err := c.getHumanWriteModelByID(ctx, session.UserID, "")
if err != nil {
return err
}
@@ -647,7 +652,9 @@ func (c *Commands) HumansSignOut(ctx context.Context, agentID string, userIDs []
events = append(events, user.NewHumanSignedOutEvent(
ctx,
UserAggregateFromWriteModel(&existingUser.WriteModel),
agentID))
agentID,
session.ID,
))
}
if len(events) == 0 {
return nil

View File

@@ -3123,9 +3123,9 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
}
type (
args struct {
ctx context.Context
agentID string
userIDs []string
ctx context.Context
agentID string
sessions []HumanSignOutSession
}
)
type res struct {
@@ -3144,9 +3144,9 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
agentID: "",
userIDs: []string{"user1"},
ctx: context.Background(),
agentID: "",
sessions: []HumanSignOutSession{{ID: "session1", UserID: "user1"}},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
@@ -3158,9 +3158,9 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
agentID: "agent1",
userIDs: []string{},
ctx: context.Background(),
agentID: "agent1",
sessions: []HumanSignOutSession{},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
@@ -3174,9 +3174,9 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
),
},
args: args{
ctx: context.Background(),
agentID: "agent1",
userIDs: []string{"user1"},
ctx: context.Background(),
agentID: "agent1",
sessions: []HumanSignOutSession{{ID: "session1", UserID: "user1"}},
},
res: res{},
},
@@ -3204,14 +3204,15 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
user.NewHumanSignedOutEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"agent1",
"session1",
),
),
),
},
args: args{
ctx: context.Background(),
agentID: "agent1",
userIDs: []string{"user1"},
ctx: context.Background(),
agentID: "agent1",
sessions: []HumanSignOutSession{{ID: "session1", UserID: "user1"}},
},
res: res{
want: &domain.ObjectDetails{
@@ -3259,18 +3260,20 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
user.NewHumanSignedOutEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"agent1",
"session1",
),
user.NewHumanSignedOutEvent(context.Background(),
&user.NewAggregate("user2", "org1").Aggregate,
"agent1",
"session2",
),
),
),
},
args: args{
ctx: context.Background(),
agentID: "agent1",
userIDs: []string{"user1", "user2"},
ctx: context.Background(),
agentID: "agent1",
sessions: []HumanSignOutSession{{ID: "session1", UserID: "user1"}, {ID: "session2", UserID: "user2"}},
},
res: res{
want: &domain.ObjectDetails{
@@ -3284,7 +3287,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
err := r.HumansSignOut(tt.args.ctx, tt.args.agentID, tt.args.userIDs)
err := r.HumansSignOut(tt.args.ctx, tt.args.agentID, tt.args.sessions)
if tt.res.err == nil {
assert.NoError(t, err)
}