mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
* Add DenyLists parsing * Remove unneeded returned error * Plug global denylist into Command * app creation: apply denylist to backchannel logout URI * Inject denylist to backchannel logout worker * webhook config: validate against blocked URLs * Add notificationsWebhook denylist target * command: Add SMTP endpoint validation against blocklist * command: Add SMS endpoint validation against blocklist * Validate webhook endpoint against denylist on channel notification * Remove unused tests * handle deprecated denylists * remove unintended denylist entry in deprecated list * use single http client * fix tests * update comments * fixes * cleanup * address comments * fix merge --------- Co-authored-by: Livio Spring <[email protected]>
510 lines
15 KiB
Go
510 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/riverqueue/river"
|
|
"github.com/riverqueue/river/rivertype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
|
es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
|
|
"github.com/zitadel/zitadel/internal/id"
|
|
id_mock "github.com/zitadel/zitadel/internal/id/mock"
|
|
"github.com/zitadel/zitadel/internal/notification/backchannel"
|
|
"github.com/zitadel/zitadel/internal/notification/channels"
|
|
channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock"
|
|
"github.com/zitadel/zitadel/internal/notification/handlers/mock"
|
|
"github.com/zitadel/zitadel/internal/notification/messages"
|
|
"github.com/zitadel/zitadel/internal/notification/senders"
|
|
"github.com/zitadel/zitadel/internal/queue"
|
|
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
|
|
)
|
|
|
|
func Test_backChannelLogoutWorker_reduceNotificationRequested(t *testing.T) {
|
|
testNow := time.Now()
|
|
sessionLogoutAgg := &sessionlogout.NewAggregate(sessionID, instanceID).Aggregate
|
|
type fields struct {
|
|
es func(*testing.T) *eventstore.Eventstore
|
|
queue func(*gomock.Controller) Queue
|
|
commands func(*gomock.Controller) Commands
|
|
queries func(*gomock.Controller) Queries
|
|
channel func(*gomock.Controller) channels.NotificationChannel
|
|
idGenerator id.Generator
|
|
}
|
|
type args struct {
|
|
job *river.Job[*backchannel.LogoutRequest]
|
|
}
|
|
type want struct {
|
|
err error
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
want want
|
|
}{
|
|
{
|
|
name: "too old",
|
|
fields: fields{
|
|
es: expectEventstore(),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
return c
|
|
},
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: new(river.JobCancelError),
|
|
},
|
|
},
|
|
{
|
|
name: "no oidc sessions with back channel logout",
|
|
fields: fields{
|
|
es: expectEventstore(
|
|
expectFilter(),
|
|
),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
return c
|
|
},
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: testNow,
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "create jobs for oidc sessions with back channel logout",
|
|
fields: fields{
|
|
es: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
sessionlogout.NewBackChannelLogoutRegisteredEvent(
|
|
context.Background(),
|
|
sessionLogoutAgg,
|
|
"oidc-session-id1",
|
|
"user-id",
|
|
"client-id1",
|
|
"back-channel-logout-uri1",
|
|
),
|
|
),
|
|
eventFromEventPusher(
|
|
sessionlogout.NewBackChannelLogoutRegisteredEvent(
|
|
context.Background(),
|
|
sessionLogoutAgg,
|
|
"oidc-session-id2",
|
|
"user-id",
|
|
"client-id2",
|
|
"back-channel-logout-uri2",
|
|
),
|
|
),
|
|
),
|
|
),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
q.EXPECT().Insert(gomock.Any(),
|
|
&backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
TriggeredAtOrigin: "",
|
|
TriggeringEventType: "",
|
|
TokenID: "id1",
|
|
UserID: "user-id",
|
|
OIDCSessionID: "oidc-session-id1",
|
|
ClientID: "client-id1",
|
|
BackChannelLogoutURI: "back-channel-logout-uri1",
|
|
},
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(backchannel.QueueName))),
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(1))),
|
|
).Return(nil)
|
|
q.EXPECT().Insert(gomock.Any(), &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
TriggeredAtOrigin: "",
|
|
TriggeringEventType: "",
|
|
TokenID: "id2",
|
|
UserID: "user-id",
|
|
OIDCSessionID: "oidc-session-id2",
|
|
ClientID: "client-id2",
|
|
BackChannelLogoutURI: "back-channel-logout-uri2",
|
|
},
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(backchannel.QueueName))),
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(1))),
|
|
).Return(nil)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
return c
|
|
},
|
|
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1", "id2"),
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: testNow,
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "send logout request",
|
|
fields: fields{
|
|
es: expectEventstore(),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
c.EXPECT().BackChannelLogoutSent(gomock.Any(), sessionID, "oidc-session-id", instanceID).Return(nil)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
q.EXPECT().GetActiveSigningWebKey(gomock.Any()).Return(
|
|
&jose.JSONWebKey{
|
|
Key: privateKey,
|
|
Algorithm: string(signingAlgorithm),
|
|
Use: "sig",
|
|
}, nil)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
c.EXPECT().HandleMessage(gomock.Any()).DoAndReturn(
|
|
func(message channels.Message) error {
|
|
logoutMessage, ok := message.(*messages.Form)
|
|
if !ok {
|
|
ctrl.T.Errorf("unexpected message type: %T", message)
|
|
}
|
|
logoutToken, ok := logoutMessage.Serializable.(*LogoutTokenMessage)
|
|
if !ok {
|
|
ctrl.T.Errorf("unexpected serializable type: %T", logoutMessage.Serializable)
|
|
}
|
|
jws, err := jose.ParseSigned(logoutToken.LogoutToken, []jose.SignatureAlgorithm{jose.RS256})
|
|
require.NoError(t, err)
|
|
payload, err := jws.Verify(privateKey.Public())
|
|
require.NoError(t, err)
|
|
var claims oidc.LogoutTokenClaims
|
|
err = json.Unmarshal(payload, &claims)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "", claims.Issuer)
|
|
assert.Equal(t, "user-id", claims.Subject)
|
|
assert.Equal(t, "client-id", claims.Audience[0])
|
|
assert.WithinRange(t, claims.IssuedAt.AsTime(), testNow.Add(-2*time.Second), time.Now())
|
|
assert.WithinRange(t, claims.Expiration.AsTime(), testNow.Add(-time.Second).Add(time.Hour), time.Now().Add(time.Hour))
|
|
assert.Equal(t, "id1", claims.JWTID)
|
|
assert.NotNil(t, claims.Events["http://schemas.openid.net/event/backchannel-logout"])
|
|
assert.Equal(t, sessionID, claims.SessionID)
|
|
|
|
return nil
|
|
})
|
|
return c
|
|
},
|
|
idGenerator: id_mock.NewIDGeneratorExpectIDs(t),
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: testNow,
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
TriggeredAtOrigin: "",
|
|
TriggeringEventType: "",
|
|
TokenID: "id1",
|
|
UserID: "user-id",
|
|
OIDCSessionID: "oidc-session-id",
|
|
ClientID: "client-id",
|
|
BackChannelLogoutURI: "back-channel-logout-uri",
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: nil,
|
|
},
|
|
},
|
|
{
|
|
name: "job creation failed",
|
|
fields: fields{
|
|
es: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
sessionlogout.NewBackChannelLogoutRegisteredEvent(
|
|
context.Background(),
|
|
sessionLogoutAgg,
|
|
"oidc-session-id1",
|
|
"user-id",
|
|
"client-id1",
|
|
"back-channel-logout-uri1",
|
|
),
|
|
),
|
|
),
|
|
),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
q.EXPECT().Insert(gomock.Any(),
|
|
gomock.Any(),
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(backchannel.QueueName))),
|
|
gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(1))),
|
|
).Return(assert.AnError)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
return c
|
|
},
|
|
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: testNow,
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: assert.AnError,
|
|
},
|
|
},
|
|
|
|
{
|
|
name: "send logout request failed",
|
|
fields: fields{
|
|
es: expectEventstore(),
|
|
queue: func(ctrl *gomock.Controller) Queue {
|
|
q := mock.NewMockQueue(ctrl)
|
|
return q
|
|
},
|
|
commands: func(ctrl *gomock.Controller) Commands {
|
|
c := mock.NewMockCommands(ctrl)
|
|
return c
|
|
},
|
|
queries: func(ctrl *gomock.Controller) Queries {
|
|
q := mock.NewMockQueries(ctrl)
|
|
q.EXPECT().GetActiveSigningWebKey(gomock.Any()).Return(
|
|
&jose.JSONWebKey{
|
|
Key: privateKey,
|
|
Algorithm: string(signingAlgorithm),
|
|
Use: "sig",
|
|
}, nil)
|
|
return q
|
|
},
|
|
channel: func(ctrl *gomock.Controller) channels.NotificationChannel {
|
|
c := channel_mock.NewMockNotificationChannel(ctrl)
|
|
c.EXPECT().HandleMessage(gomock.Any()).Return(assert.AnError)
|
|
return c
|
|
},
|
|
idGenerator: id_mock.NewIDGeneratorExpectIDs(t),
|
|
},
|
|
args: args{
|
|
job: &river.Job[*backchannel.LogoutRequest]{
|
|
JobRow: &rivertype.JobRow{
|
|
CreatedAt: testNow,
|
|
},
|
|
Args: &backchannel.LogoutRequest{
|
|
Aggregate: sessionLogoutAgg,
|
|
SessionID: sessionID,
|
|
TriggeredAtOrigin: "",
|
|
TriggeringEventType: "",
|
|
TokenID: "id1",
|
|
UserID: "user-id",
|
|
OIDCSessionID: "oidc-session-id",
|
|
ClientID: "client-id",
|
|
BackChannelLogoutURI: "back-channel-logout-uri",
|
|
},
|
|
},
|
|
},
|
|
want: want{
|
|
err: assert.AnError,
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
err := newBackChannelLogoutWorker(
|
|
tt.fields.queries(ctrl),
|
|
tt.fields.commands(ctrl),
|
|
tt.fields.es(t),
|
|
tt.fields.queue(ctrl),
|
|
tt.fields.channel(ctrl),
|
|
tt.fields.idGenerator,
|
|
func() time.Time { return testNow },
|
|
).Work(
|
|
authz.WithInstanceID(context.Background(), instanceID),
|
|
tt.args.job,
|
|
)
|
|
assert.ErrorIs(t, err, tt.want.err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func newBackChannelLogoutWorker(queries Queries, commands Commands, es *eventstore.Eventstore, queue Queue, channel channels.NotificationChannel, idGenerator id.Generator, testNow func() time.Time) *BackChannelLogoutWorker {
|
|
return &BackChannelLogoutWorker{
|
|
commands: commands,
|
|
queries: NewNotificationQueries(
|
|
queries,
|
|
es,
|
|
externalDomain,
|
|
externalPort,
|
|
externalSecure,
|
|
"",
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
),
|
|
eventstore: es,
|
|
queue: queue,
|
|
channels: ¬ificationChannels{
|
|
Chain: *senders.ChainChannels(channel),
|
|
},
|
|
config: &BackChannelLogoutWorkerConfig{
|
|
Workers: 1,
|
|
TransactionDuration: 5 * time.Second,
|
|
MaxTtl: 5 * time.Minute,
|
|
MaxAttempts: 1,
|
|
TokenLifetime: time.Hour,
|
|
},
|
|
now: testNow,
|
|
idGenerator: idGenerator,
|
|
}
|
|
}
|
|
|
|
type expect func(mockRepository *es_repo_mock.MockRepository)
|
|
|
|
func expectEventstore(expects ...expect) func(*testing.T) *eventstore.Eventstore {
|
|
return func(t *testing.T) *eventstore.Eventstore {
|
|
m := es_repo_mock.NewRepo(t)
|
|
for _, e := range expects {
|
|
e(m)
|
|
}
|
|
es := eventstore.NewEventstore(
|
|
&eventstore.Config{
|
|
Querier: m.MockQuerier,
|
|
Pusher: m.MockPusher,
|
|
},
|
|
)
|
|
return es
|
|
}
|
|
}
|
|
|
|
func expectFilter(events ...eventstore.Event) expect {
|
|
return func(m *es_repo_mock.MockRepository) {
|
|
m.ExpectFilterEvents(events...)
|
|
}
|
|
}
|
|
|
|
func eventFromEventPusher(event eventstore.Command) *repository.Event {
|
|
data, _ := eventstore.EventData(event)
|
|
return &repository.Event{
|
|
InstanceID: event.Aggregate().InstanceID,
|
|
ID: "",
|
|
Seq: 0,
|
|
CreationDate: time.Time{},
|
|
Typ: event.Type(),
|
|
Data: data,
|
|
EditorUser: event.Creator(),
|
|
Version: event.Aggregate().Version,
|
|
AggregateID: event.Aggregate().ID,
|
|
AggregateType: event.Aggregate().Type,
|
|
ResourceOwner: sql.NullString{String: event.Aggregate().ResourceOwner, Valid: event.Aggregate().ResourceOwner != ""},
|
|
Constraints: event.UniqueConstraints(),
|
|
}
|
|
}
|
|
|
|
var (
|
|
privateKey = func() *rsa.PrivateKey {
|
|
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
return privateKey
|
|
}()
|
|
signingAlgorithm = jose.RS256
|
|
)
|