mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 07:57:32 +00:00
chore: move the go code into a subfolder
This commit is contained in:
163
apps/api/internal/integration/action.go
Normal file
163
apps/api/internal/integration/action.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
server *httptest.Server
|
||||
mu sync.Mutex
|
||||
called int
|
||||
}
|
||||
|
||||
func (s *server) URL() string {
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *server) Close() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
func (s *server) Called() int {
|
||||
s.mu.Lock()
|
||||
called := s.called
|
||||
s.mu.Unlock()
|
||||
return called
|
||||
}
|
||||
|
||||
func (s *server) Increase() {
|
||||
s.mu.Lock()
|
||||
s.called++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) ResetCalled() {
|
||||
s.mu.Lock()
|
||||
s.called = 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestServerCall(
|
||||
reqBody interface{},
|
||||
sleep time.Duration,
|
||||
statusCode int,
|
||||
respBody interface{},
|
||||
) (url string, closeF func(), calledF func() int, resetCalledF func()) {
|
||||
server := &server{
|
||||
called: 0,
|
||||
}
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
server.Increase()
|
||||
if reqBody != nil {
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
http.Error(w, "error, marshall: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sentBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "error, read body: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(data, sentBody) {
|
||||
http.Error(w, "error, equal:\n"+string(data)+"\nsent:\n"+string(sentBody), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
http.Error(w, "error, statusCode", statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(sleep)
|
||||
|
||||
if respBody != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp, err := json.Marshal(respBody)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := io.Writer.Write(w, resp); err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if _, err := io.WriteString(w, "finished successfully"); err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.server = httptest.NewServer(http.HandlerFunc(handler))
|
||||
return server.URL(), server.Close, server.Called, server.ResetCalled
|
||||
}
|
||||
|
||||
func TestServerCallProto(
|
||||
reqBody interface{},
|
||||
sleep time.Duration,
|
||||
statusCode int,
|
||||
respBody proto.Message,
|
||||
) (url string, closeF func(), calledF func() int, resetCalledF func()) {
|
||||
server := &server{
|
||||
called: 0,
|
||||
}
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
server.Increase()
|
||||
if reqBody != nil {
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
http.Error(w, "error, marshall: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sentBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "error, read body: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(data, sentBody) {
|
||||
http.Error(w, "error, equal:\n"+string(data)+"\nsent:\n"+string(sentBody), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
http.Error(w, "error, statusCode", statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(sleep)
|
||||
|
||||
if respBody != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp, err := protojson.Marshal(respBody)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := io.Writer.Write(w, resp); err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if _, err := io.WriteString(w, "finished successfully"); err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.server = httptest.NewServer(http.HandlerFunc(handler))
|
||||
return server.URL(), server.Close, server.Called, server.ResetCalled
|
||||
}
|
178
apps/api/internal/integration/assert.go
Normal file
178
apps/api/internal/integration/assert.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pmezard/go-difflib/difflib"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
resources_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha"
|
||||
)
|
||||
|
||||
// Details is the interface that covers both v1 and v2 proto generated object details.
|
||||
type Details interface {
|
||||
comparable
|
||||
GetSequence() uint64
|
||||
GetCreationDate() *timestamppb.Timestamp
|
||||
GetChangeDate() *timestamppb.Timestamp
|
||||
GetResourceOwner() string
|
||||
}
|
||||
|
||||
// DetailsMsg is the interface that covers all proto messages which contain v1 or v2 object details.
|
||||
type DetailsMsg[D Details] interface {
|
||||
GetDetails() D
|
||||
}
|
||||
|
||||
type ListDetails interface {
|
||||
comparable
|
||||
GetTotalResult() uint64
|
||||
GetTimestamp() *timestamppb.Timestamp
|
||||
}
|
||||
|
||||
type ListDetailsMsg[L ListDetails] interface {
|
||||
GetDetails() L
|
||||
}
|
||||
|
||||
type ResourceListDetailsMsg interface {
|
||||
GetDetails() *resources_object.ListDetails
|
||||
}
|
||||
|
||||
// AssertDetails asserts values in a message's object Details,
|
||||
// if the object Details in expected is a non-nil value.
|
||||
// It targets API v2 messages that have the `GetDetails()` method.
|
||||
//
|
||||
// Dynamically generated values are not compared with expected.
|
||||
// Instead a sanity check is performed.
|
||||
// For the sequence a non-zero value is expected.
|
||||
// If the change date is populated, it is checked with a tolerance of 1 minute around Now.
|
||||
//
|
||||
// The resource owner is compared with expected.
|
||||
func AssertDetails[D Details, M DetailsMsg[D]](t assert.TestingT, expected, actual M) {
|
||||
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
|
||||
var nilDetails D
|
||||
if wantDetails == nilDetails {
|
||||
assert.Nil(t, gotDetails)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotZero(t, gotDetails.GetSequence())
|
||||
|
||||
if wantDetails.GetCreationDate() != nil {
|
||||
wantCreationDate := time.Now()
|
||||
gotCreationDate := gotDetails.GetCreationDate().AsTime()
|
||||
assert.WithinRange(t, gotCreationDate, wantCreationDate.Add(-time.Minute), wantCreationDate.Add(time.Minute))
|
||||
}
|
||||
|
||||
if wantDetails.GetChangeDate() != nil {
|
||||
wantChangeDate := time.Now()
|
||||
gotChangeDate := gotDetails.GetChangeDate().AsTime()
|
||||
assert.WithinRange(t, gotChangeDate, wantChangeDate.Add(-time.Minute), wantChangeDate.Add(time.Minute))
|
||||
}
|
||||
|
||||
assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner())
|
||||
}
|
||||
|
||||
func AssertResourceDetails(t assert.TestingT, expected *resources_object.Details, actual *resources_object.Details) {
|
||||
if expected.GetChanged() != nil {
|
||||
wantChangeDate := time.Now()
|
||||
gotChangeDate := actual.GetChanged().AsTime()
|
||||
assert.WithinRange(t, gotChangeDate, wantChangeDate.Add(-time.Minute), wantChangeDate.Add(time.Minute))
|
||||
}
|
||||
if expected.GetCreated() != nil {
|
||||
wantCreatedDate := time.Now()
|
||||
gotCreatedDate := actual.GetCreated().AsTime()
|
||||
assert.WithinRange(t, gotCreatedDate, wantCreatedDate.Add(-time.Minute), wantCreatedDate.Add(time.Minute))
|
||||
}
|
||||
if expected.GetOwner() != nil {
|
||||
expectedOwner := expected.GetOwner()
|
||||
actualOwner := actual.GetOwner()
|
||||
if !assert.NotNil(t, actualOwner) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, expectedOwner.GetId(), actualOwner.GetId())
|
||||
assert.Equal(t, expectedOwner.GetType(), actualOwner.GetType())
|
||||
}
|
||||
assert.NotEmpty(t, actual.GetId())
|
||||
if expected.GetId() != "" {
|
||||
assert.Equal(t, expected.GetId(), actual.GetId())
|
||||
}
|
||||
}
|
||||
|
||||
func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t assert.TestingT, expected, actual D) {
|
||||
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
|
||||
var nilDetails L
|
||||
if wantDetails == nilDetails {
|
||||
assert.Nil(t, gotDetails)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult())
|
||||
|
||||
if wantDetails.GetTimestamp() != nil {
|
||||
gotCD := gotDetails.GetTimestamp().AsTime()
|
||||
wantCD := time.Now()
|
||||
assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
func AssertResourceListDetails[D ResourceListDetailsMsg](t assert.TestingT, expected, actual D) {
|
||||
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
|
||||
if wantDetails == nil {
|
||||
assert.Nil(t, gotDetails)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult())
|
||||
assert.Equal(t, wantDetails.GetAppliedLimit(), gotDetails.GetAppliedLimit())
|
||||
|
||||
if wantDetails.GetTimestamp() != nil {
|
||||
gotCD := gotDetails.GetTimestamp().AsTime()
|
||||
wantCD := time.Now()
|
||||
assert.WithinRange(t, gotCD, wantCD.Add(-10*time.Minute), wantCD.Add(time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
func AssertGrpcStatus(t assert.TestingT, expected codes.Code, err error) {
|
||||
assert.Error(t, err)
|
||||
statusErr, ok := status.FromError(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, expected, statusErr.Code())
|
||||
}
|
||||
|
||||
// EqualProto is inspired by [assert.Equal], only that it tests equality of a proto message.
|
||||
// A message diff is printed on the error test log if the messages are not equal.
|
||||
//
|
||||
// As [assert.Equal] is based on reflection, comparing 2 proto messages sometimes fails,
|
||||
// due to their internal state.
|
||||
// Expected messages are usually with a vanilla state, eg only exported fields contain data.
|
||||
// Actual messages obtained from the gRPC client had unexported fields with data.
|
||||
// This makes them hard to compare.
|
||||
func EqualProto(t testing.TB, expected, actual proto.Message) bool {
|
||||
t.Helper()
|
||||
if proto.Equal(expected, actual) {
|
||||
return true
|
||||
}
|
||||
t.Errorf("Proto messages not equal: %s", diffProto(expected, actual))
|
||||
return false
|
||||
}
|
||||
|
||||
func diffProto(expected, actual proto.Message) string {
|
||||
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
|
||||
A: difflib.SplitLines(protojson.Format(expected)),
|
||||
B: difflib.SplitLines(protojson.Format(actual)),
|
||||
FromFile: "Expected",
|
||||
FromDate: "",
|
||||
ToFile: "Actual",
|
||||
ToDate: "",
|
||||
Context: 1,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "\n\nDiff:\n" + diff
|
||||
}
|
52
apps/api/internal/integration/assert_test.go
Normal file
52
apps/api/internal/integration/assert_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
|
||||
)
|
||||
|
||||
type myMsg struct {
|
||||
details *object.Details
|
||||
}
|
||||
|
||||
func (m myMsg) GetDetails() *object.Details {
|
||||
return m.details
|
||||
}
|
||||
|
||||
func TestAssertDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exptected myMsg
|
||||
actual myMsg
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
exptected: myMsg{},
|
||||
actual: myMsg{},
|
||||
},
|
||||
{
|
||||
name: "values",
|
||||
exptected: myMsg{
|
||||
details: &object.Details{
|
||||
ResourceOwner: "me",
|
||||
ChangeDate: timestamppb.Now(),
|
||||
},
|
||||
},
|
||||
actual: myMsg{
|
||||
details: &object.Details{
|
||||
Sequence: 123,
|
||||
ChangeDate: timestamppb.Now(),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
AssertDetails(t, tt.exptected, tt.actual)
|
||||
})
|
||||
}
|
||||
}
|
1228
apps/api/internal/integration/client.go
Normal file
1228
apps/api/internal/integration/client.go
Normal file
File diff suppressed because it is too large
Load Diff
52
apps/api/internal/integration/config.go
Normal file
52
apps/api/internal/integration/config.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Log *logging.Config
|
||||
Hostname string
|
||||
Port uint16
|
||||
Secure bool
|
||||
LoginURLV2 string
|
||||
LogoutURLV2 string
|
||||
WebAuthNName string
|
||||
}
|
||||
|
||||
//go:embed config/client.yaml
|
||||
var clientYAML []byte
|
||||
|
||||
var (
|
||||
tmpDir string
|
||||
loadedConfig Config
|
||||
)
|
||||
|
||||
// TmpDir returns the absolute path to the projects's temp directory.
|
||||
func TmpDir() string {
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tmpDir = filepath.Join(string(bytes.TrimSpace(out)), "tmp")
|
||||
|
||||
if err := yaml.Unmarshal(clientYAML, &loadedConfig); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := loadedConfig.Log.SetLogger(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
SystemToken = createSystemUserToken()
|
||||
SystemUserWithNoPermissionsToken = createSystemUserWithNoPermissionsToken()
|
||||
}
|
10
apps/api/internal/integration/config/client.yaml
Normal file
10
apps/api/internal/integration/config/client.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
Log:
|
||||
Level: info
|
||||
Formatter:
|
||||
Format: text
|
||||
Hostname: localhost
|
||||
Port: 8080
|
||||
Secure: false
|
||||
LoginURLV2: "/login?authRequest="
|
||||
LogoutURLV2: "/logout?post_logout_redirect="
|
||||
WebAuthNName: ZITADEL
|
26
apps/api/internal/integration/config/docker-compose.yaml
Normal file
26
apps/api/internal/integration/config/docker-compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: 'always'
|
||||
image: 'postgres:latest'
|
||||
environment:
|
||||
- POSTGRES_USER=zitadel
|
||||
- PGUSER=zitadel
|
||||
- POSTGRES_DB=zitadel
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
cache:
|
||||
restart: 'always'
|
||||
image: 'redis:latest'
|
||||
ports:
|
||||
- 6379:6379
|
14
apps/api/internal/integration/config/postgres.yaml
Normal file
14
apps/api/internal/integration/config/postgres.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
Database:
|
||||
postgres:
|
||||
MaxOpenConns: 20
|
||||
MaxIdleConns: 20
|
||||
MaxConnLifetime: 1h
|
||||
MaxConnIdleTime: 5m
|
||||
User:
|
||||
Password: zitadel
|
||||
SSL:
|
||||
Mode: disable
|
||||
Admin:
|
||||
Username: zitadel
|
||||
SSL:
|
||||
Mode: disable
|
13
apps/api/internal/integration/config/steps.yaml
Normal file
13
apps/api/internal/integration/config/steps.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
FirstInstance:
|
||||
Skip: false
|
||||
PatPath: tmp/admin-pat.txt
|
||||
InstanceName: ZITADEL
|
||||
DefaultLanguage: en
|
||||
Org:
|
||||
Name: ZITADEL
|
||||
Machine:
|
||||
Machine:
|
||||
Username: boss
|
||||
Name: boss
|
||||
Pat:
|
||||
ExpirationDate: 2099-01-01T00:00:00Z
|
27
apps/api/internal/integration/config/system-user-key.pem
Normal file
27
apps/api/internal/integration/config/system-user-key.pem
Normal file
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAzi+FFSJL7f5yw4KTwzgMP34ePGycm/M+kT0M7V4Cgx5V3EaD
|
||||
IvTQKTLfBaEB45zb9LtjIXzDw0rXRoS2hO6th+CYQCz3KCvh09C0IzxZiB2IS3H/
|
||||
aT+5Bx9EFY+vnAkZjccbyG5YNRvmtOlnvIeIH7qZ0tEwkPfF5GEZNPJPtmy3UGV7
|
||||
iofdVQS1xRj73+aMw5rvH4D8IdyiAC3VekIbpt0Vj0SUX3DwKtog337BzTiPk3aX
|
||||
RF0sbFhQoqdJRI8NqgZjCwjq9yfI5tyxYswn+JGzHGdHvW3idODlmwEt5K2pasiR
|
||||
IWK2OGfq+w0EcltQHabuqEPgZlmhCkRdNfixBwIDAQABAoIBAA9jNoBkRdxmH/R9
|
||||
Wz+3gBqA9Aq4ZFuzJJk8QCm62V8ltWyyCnliYeKhPEm0QWrWOwghr/1AzW9Wt4g4
|
||||
wVJcabD5TwODF5L0626eZcM3bsscwR44TMJzEgD5EWC2j3mKqFCPaoBj08tq4KXh
|
||||
wW8tgjgz+eTk3cYD583qfTIZX1+SzSMBpetTBsssQtGhhOB/xPiuL7hi+fXmV2rh
|
||||
8mc9X6+wJ5u3zepsyK0vBeEDmurD4ZUIXFrZ0WCB/wNkSW9VKyoH+RC1asQAgqTz
|
||||
glJ/NPbDJSKGvSBQydoKkqoXx7MVJ8VObFddfgo4dtOoz6YCfUVBHt8qy+E5rz5y
|
||||
CICjL/kCgYEA9MnHntVVKNXtEFZPo02xgCwS3eG27ZwjYgJ1ZkCHM5BuL4MS7qbr
|
||||
743/POs1Ctaok0udHl1PFB4uAG0URnmkUnWzcoJYb6Plv03F0LRdsnfuhehfIxLP
|
||||
nWvxSm5n21H4ytfxm0BWY09JkLDnJZtXrgTILbuqb9Wy6TmAvUaF2YUCgYEA16Ec
|
||||
ywSaLVdqPaVpsTxi7XpRJAB2Isjp6RffNEecta4S0LL7s/IO3QXDH9SYpgmgCTah
|
||||
3aXhpT4hIFlpg3eBjVfbOwgqub8DgirnSQyQt99edUtHIK+K8nMdGxz6X6pfTKzK
|
||||
asSH7qPlt5tz1621vC0ocXSZR7zm99/FgwILwBsCgYBOsP8nJFV4By1qbxSy3qsN
|
||||
FR4LjiAMSoFlZHzxHhVYkjmZtH1FkwuNuwwuPT6T+WW/1DLyK/Tb9se7A1XdQgV9
|
||||
LLE/Qn/Dg+C7mvjYmuL0GHHpQkYzNDzh0m2DC/L/Il7kdn8I9anPyxFPHk9wW3vY
|
||||
SVlAum+T/BLDvuSP9DfbMQKBgCc1j7PG8XYfOB1fj7l/volqPYjrYI/wssAE7Dxo
|
||||
bTGIJrm2YhiVgmhkXNfT47IFfAlQ2twgBsjyZDmqqIoUWAVonV+9m29NMYkg3g+l
|
||||
bkdRIa74ckWaRgzSK8+7VDfDFjMuFFyXwhP9z460gLsORkaie4Et75Vg3yrhkNvC
|
||||
qnpTAoGBAMguDSWBbCewXnHlKGFpm+LH+OIvVKGEhtCSvfZojtNrg/JBeBebSL1n
|
||||
mmT1cONO+0O5bz7uVaRd3JdnH2JFevY698zFfhVsjVCrm+fz31i5cxAgC39G2Lfl
|
||||
YkTaa1AFLstnf348ZjuvBN3USUYZo3X3mxnS+uluVuRSGwIKsN0a
|
||||
-----END RSA PRIVATE KEY-----
|
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCMxYRfqb4fdnBl
|
||||
ZmYweqUaZnWQv8RhWDYGifYGen00ozCFT2L6gGov4YCxRVe+l3aFQ79j5SJb1C+v
|
||||
H68DJkyCTrhDpATqdjVuCu7CEEI//16Ivfmj3gbNdsp0IcDKVIAF0bN9kve5ofRX
|
||||
CgU6DIx8GjLsXSooSniZnJ4d/Rnt69mpSsPkykUs3RpG2NSOn3WLAoVKh1q/kqeV
|
||||
qf8eQ+KzuyD/R9QNAPiyB+ivAuOtVuvmIqojQYK5o8veTg/waBxdmzkim7eg8J7B
|
||||
VDSjBeHagS5K9IJr/Q2VeO0rZOOeJfLlH9xlSrDvc3AIS/3HtkqI268kNkvpGz0I
|
||||
sg61pUQtAgMBAAECggEAFzZrv1WPaQNAAex6fdR/fKS4Dqwcjxu7XuUpeUSB+GfP
|
||||
dLAUR2/c8rPJ45FmaGJz9AIpoWiTe5Z33XYJRyjt1U/zQQ4fFGV1JoXtfHkvX3u1
|
||||
5DEFZQDT2NYViMRXFNYNvUfow9Rz/nuG/cJEfd+7W6x7SLANJ1MuY1Ao35OQjsOG
|
||||
ftTtmEUppEIXyWL0PCeHQc83z8aJrP+p4hpjJOW2mui0NR2Hk456DGYXg8I8fcQD
|
||||
ar7Ar7/A6thR0OmwG7tkkLjRiCjGwnkr19hCNLz+QAWB2o284T12zZueOqRuYQzu
|
||||
KwNBZKJlClsPkhdZSPLL4RMFP6hJjKoP5mY0Zdzh8QKBgQDEPrM70aZQiweXHqoE
|
||||
/vZry7tphGycoEAf6nwBBrZaRPpJdnEA61LBlJFv7C3s59uy6L7nHssTyVUJha9i
|
||||
zFCWRQ0mHNrwxF5Ybd5p//hgblt3X53IV6vZBFF1+OrwRS/AKki3GynDc/oI++hu
|
||||
PGHWmUF6lIi3uzWwOTqk6EGovQKBgQC3oqpUlpJ78e0zPjIr9ov61TtnPzAa883D
|
||||
LL7fuNYP9zxIMoFZw++2bZfT5tbINflQdZnVVDNs5KiwtEu3oZJrsqXpQmzCl3j2
|
||||
KA9FTdVJQXc2lU90uYb76c5JZPownojbXFFOPQokBqfsYLSdfvNVHSQGjZ3C90wL
|
||||
YZC0vA9YMQKBgQDCKSraD2YWoEeFO+CJityx8GNfVZbELETljvDbbxGyJDbhwh6y
|
||||
AyHgxyZR7wHNN+UFkQN31d6kl/jbr/nDrVQ6KN2GjNwNhKu3oBSDGa9bcTRr2h1Y
|
||||
32z2DTCvoPSJflptLSi+iVB7wd5rTxk7H+DJGt5O8nCGH+JRlX2xNN3pnQKBgDdA
|
||||
u21eLM8cWNmNQj1WHoInfIsxSQEjEGtEYF4iWE5PfpTelWrz+IF0cjVxBHkTPGPI
|
||||
LrQwdJS0LEmWxh2HgO3kv+TydpUKTHwMS6P3qlAzYXJL9K9TT1km3UnaFylf2h/e
|
||||
pBwdY5q5YfdOlam50+9tKDTMkYZjMD9QaODooNlRAoGAOWow99WCATFtRrG+mGyl
|
||||
UpwApgkZKT0nhkXUnLdNoQVeP0WHeQBSoOA24YnGBntvG/98Uj2rOwdCAYzTGepz
|
||||
91bNqscrSOPdD3VN85GEl2DQKtxsRCKCdPKmYkvC/WMGhuzXSIp2U+ePgqEjEQO2
|
||||
Sn4xXZ1zwl+4cYHmDvzEQnA=
|
||||
-----END PRIVATE KEY-----
|
110
apps/api/internal/integration/config/zitadel.yaml
Normal file
110
apps/api/internal/integration/config/zitadel.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
Log:
|
||||
Level: info
|
||||
|
||||
ExternalSecure: false
|
||||
|
||||
TLS:
|
||||
Enabled: false
|
||||
|
||||
Caches:
|
||||
Connectors:
|
||||
Memory:
|
||||
Enabled: true
|
||||
Postgres:
|
||||
Enabled: true
|
||||
Redis:
|
||||
Enabled: true
|
||||
Instance:
|
||||
Connector: "memory"
|
||||
MaxAge: 5m
|
||||
LastUsage: 1m
|
||||
Log:
|
||||
Level: info
|
||||
Milestones:
|
||||
Connector: "postgres"
|
||||
MaxAge: 5m
|
||||
LastUsage: 1m
|
||||
Log:
|
||||
Level: info
|
||||
Organization:
|
||||
Connector: "redis"
|
||||
MaxAge: 5m
|
||||
LastUsage: 1m
|
||||
Log:
|
||||
Level: info
|
||||
|
||||
Quotas:
|
||||
Access:
|
||||
Enabled: true
|
||||
|
||||
Telemetry:
|
||||
Enabled: true
|
||||
Endpoints:
|
||||
- http://localhost:8081/milestone
|
||||
Headers:
|
||||
single-value: "single-value"
|
||||
multi-value:
|
||||
- "multi-value-1"
|
||||
- "multi-value-2"
|
||||
|
||||
FirstInstance:
|
||||
Org:
|
||||
Human:
|
||||
PasswordChangeRequired: false
|
||||
|
||||
LogStore:
|
||||
Execution:
|
||||
Stdout:
|
||||
Enabled: true
|
||||
|
||||
Projections:
|
||||
HandleActiveInstances: 30m
|
||||
RequeueEvery: 5s
|
||||
Customizations:
|
||||
NotificationsQuotas:
|
||||
RequeueEvery: 1s
|
||||
telemetry:
|
||||
HandleActiveInstances: 60s
|
||||
RequeueEvery: 1s
|
||||
|
||||
DefaultInstance:
|
||||
LoginPolicy:
|
||||
MfaInitSkipLifetime: "0"
|
||||
|
||||
SystemAPIUsers:
|
||||
- tester:
|
||||
KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
|
||||
Memberships:
|
||||
- MemberType: System
|
||||
Roles:
|
||||
- "SYSTEM_OWNER"
|
||||
- "IAM_OWNER"
|
||||
- "ORG_OWNER"
|
||||
- cypress:
|
||||
KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
|
||||
- system-user-with-no-permissions:
|
||||
KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFqTVdFWDZtK0gzWndaV1ptTUhxbApHbVoxa0wvRVlWZzJCb24yQm5wOU5LTXdoVTlpK29CcUwrR0FzVVZYdnBkMmhVTy9ZK1VpVzlRdnJ4K3ZBeVpNCmdrNjRRNlFFNm5ZMWJncnV3aEJDUC85ZWlMMzVvOTRHelhiS2RDSEF5bFNBQmRHemZaTDN1YUgwVndvRk9neU0KZkJveTdGMHFLRXA0bVp5ZUhmMFo3ZXZacVVyRDVNcEZMTjBhUnRqVWpwOTFpd0tGU29kYXY1S25sYW4vSGtQaQpzN3NnLzBmVURRRDRzZ2ZvcndManJWYnI1aUtxSTBHQ3VhUEwzazRQOEdnY1haczVJcHUzb1BDZXdWUTBvd1hoCjJvRXVTdlNDYS8wTmxYanRLMlRqbmlYeTVSL2NaVXF3NzNOd0NFdjl4N1pLaU51dkpEWkw2UnM5Q0xJT3RhVkUKTFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
|
||||
Memberships:
|
||||
# MemberType System allows the user to access all APIs for all instances or organizations
|
||||
- MemberType: IAM
|
||||
Roles:
|
||||
- "NO_ROLES"
|
||||
|
||||
InitProjections:
|
||||
Enabled: true
|
||||
|
||||
# Extend key lifetimes so we do not see more legacy keys when
|
||||
# integration tests are rerun on the same DB with more than 6 hours apart.
|
||||
# The test counts the amount of keys returned from the JWKS endpoint and fails
|
||||
# with 2 or more legacy public keys,
|
||||
SystemDefaults:
|
||||
KeyConfig:
|
||||
PrivateKeyLifetime: 7200h
|
||||
PublicKeyLifetime: 14400h
|
||||
|
||||
OIDC:
|
||||
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2
|
||||
DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2
|
||||
|
||||
SAML:
|
||||
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2
|
30
apps/api/internal/integration/context.go
Normal file
30
apps/api/internal/integration/context.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WaitForAndTickWithMaxDuration determine a duration and interval for EventuallyWithT-tests from context timeout and desired max duration
|
||||
func WaitForAndTickWithMaxDuration(ctx context.Context, max time.Duration) (time.Duration, time.Duration) {
|
||||
// interval which is used to retry the test
|
||||
tick := time.Millisecond * 100
|
||||
// tolerance which is used to stop the test for the timeout
|
||||
tolerance := tick * 5
|
||||
// default of the WaitFor is always a defined duration, shortened if the context would time out before
|
||||
waitFor := max
|
||||
|
||||
if ctxDeadline, ok := ctx.Deadline(); ok {
|
||||
// if the context has a deadline, set the WaitFor to the shorter duration
|
||||
if until := time.Until(ctxDeadline); until < waitFor {
|
||||
// ignore durations which are smaller than the tolerance
|
||||
if until < tolerance {
|
||||
waitFor = 0
|
||||
} else {
|
||||
// always let the test stop with tolerance before the context is in timeout
|
||||
waitFor = until - tolerance
|
||||
}
|
||||
}
|
||||
}
|
||||
return waitFor, tick
|
||||
}
|
30
apps/api/internal/integration/feature.go
Normal file
30
apps/api/internal/integration/feature.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
||||
)
|
||||
|
||||
func EnsureInstanceFeature(t *testing.T, ctx context.Context, instance *Instance, features *feature.SetInstanceFeaturesRequest, assertFeatures func(t *assert.CollectT, got *feature.GetInstanceFeaturesResponse)) {
|
||||
ctx = instance.WithAuthorizationToken(ctx, UserTypeIAMOwner)
|
||||
_, err := instance.Client.FeatureV2.SetInstanceFeatures(ctx, features)
|
||||
require.NoError(t, err)
|
||||
retryDuration, tick := WaitForAndTickWithMaxDuration(ctx, 5*time.Minute)
|
||||
require.EventuallyWithT(t,
|
||||
func(tt *assert.CollectT) {
|
||||
got, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{
|
||||
Inheritance: true,
|
||||
})
|
||||
require.NoError(tt, err)
|
||||
assertFeatures(tt, got)
|
||||
},
|
||||
retryDuration,
|
||||
tick,
|
||||
"timed out waiting for ensuring instance feature")
|
||||
}
|
353
apps/api/internal/integration/instance.go
Normal file
353
apps/api/internal/integration/instance.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Package integration provides helpers for integration testing.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/zitadel/logging"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/auth"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/org"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/system"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user"
|
||||
user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
// NotEmpty can be used as placeholder, when the returned values is unknown.
|
||||
// It can be used in tests to assert whether a value should be empty or not.
|
||||
const NotEmpty = "not empty"
|
||||
|
||||
const (
|
||||
adminPATFile = "admin-pat.txt"
|
||||
)
|
||||
|
||||
// UserType provides constants that give
|
||||
// a short explanation with the purpose
|
||||
// a service user.
|
||||
// This allows to pre-create users with
|
||||
// different permissions and reuse them.
|
||||
type UserType int
|
||||
|
||||
//go:generate enumer -type UserType -transform snake -trimprefix UserType
|
||||
const (
|
||||
UserTypeUnspecified UserType = iota
|
||||
UserTypeIAMOwner
|
||||
UserTypeOrgOwner
|
||||
UserTypeLogin
|
||||
UserTypeNoPermission
|
||||
)
|
||||
|
||||
const (
|
||||
UserPassword = "VeryS3cret!"
|
||||
)
|
||||
|
||||
const (
|
||||
PortMilestoneServer = "8081"
|
||||
PortQuotaServer = "8082"
|
||||
)
|
||||
|
||||
// User information with a Personal Access Token.
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Token string
|
||||
}
|
||||
|
||||
type UserMap map[UserType]*User
|
||||
|
||||
func (m UserMap) Set(typ UserType, user *User) {
|
||||
m[typ] = user
|
||||
}
|
||||
|
||||
func (m UserMap) Get(typ UserType) *User {
|
||||
return m[typ]
|
||||
}
|
||||
|
||||
// Host returns the primary host of zitadel, on which the first instance is served.
|
||||
// http://localhost:8080 by default
|
||||
func (c *Config) Host() string {
|
||||
return fmt.Sprintf("%s:%d", c.Hostname, c.Port)
|
||||
}
|
||||
|
||||
// Instance is a Zitadel server and client with all resources available for testing.
|
||||
type Instance struct {
|
||||
Config Config
|
||||
Domain string
|
||||
Instance *instance.InstanceDetail
|
||||
DefaultOrg *org.Org
|
||||
Users UserMap
|
||||
AdminUserID string // First human user for password login
|
||||
|
||||
Client *Client
|
||||
WebAuthN *webauthn.Client
|
||||
}
|
||||
|
||||
// NewInstance returns a new instance that can be used for integration tests.
|
||||
// The instance contains a gRPC client connected to the domain of this instance.
|
||||
// The included users are the IAM_OWNER, ORG_OWNER of the default org and
|
||||
// a Login client user.
|
||||
//
|
||||
// The instance is isolated and is safe for parallel testing.
|
||||
func NewInstance(ctx context.Context) *Instance {
|
||||
primaryDomain := RandString(5) + ".integration.localhost"
|
||||
|
||||
ctx = WithSystemAuthorization(ctx)
|
||||
resp, err := SystemClient().CreateInstance(ctx, &system.CreateInstanceRequest{
|
||||
InstanceName: "testinstance",
|
||||
CustomDomain: primaryDomain,
|
||||
Owner: &system.CreateInstanceRequest_Machine_{
|
||||
Machine: &system.CreateInstanceRequest_Machine{
|
||||
UserName: "owner",
|
||||
Name: "owner",
|
||||
PersonalAccessToken: &system.CreateInstanceRequest_PersonalAccessToken{},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
i := &Instance{
|
||||
Config: loadedConfig,
|
||||
Domain: primaryDomain,
|
||||
}
|
||||
i.setClient(ctx)
|
||||
i.awaitFirstUser(WithAuthorizationToken(ctx, resp.GetPat()))
|
||||
i.setupInstance(ctx, resp.GetPat())
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *Instance) ID() string {
|
||||
return i.Instance.GetId()
|
||||
}
|
||||
|
||||
func (i *Instance) awaitFirstUser(ctx context.Context) {
|
||||
var allErrs []error
|
||||
for {
|
||||
resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{
|
||||
Username: proto.String("zitadel-admin@zitadel.localhost"),
|
||||
Profile: &user_v2.SetHumanProfile{
|
||||
GivenName: "hodor",
|
||||
FamilyName: "hodor",
|
||||
NickName: proto.String("hodor"),
|
||||
},
|
||||
Email: &user_v2.SetHumanEmail{
|
||||
Email: "zitadel-admin@zitadel.localhost",
|
||||
Verification: &user_v2.SetHumanEmail_IsVerified{
|
||||
IsVerified: true,
|
||||
},
|
||||
},
|
||||
PasswordType: &user_v2.AddHumanUserRequest_Password{
|
||||
Password: &user_v2.Password{
|
||||
Password: "Password1!",
|
||||
ChangeRequired: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
i.AdminUserID = resp.GetUserId()
|
||||
return
|
||||
}
|
||||
logging.WithError(err).Debug("await first instance user")
|
||||
allErrs = append(allErrs, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
panic(errors.Join(append(allErrs, ctx.Err())...))
|
||||
case <-time.After(time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Instance) setupInstance(ctx context.Context, token string) {
|
||||
i.Users = make(UserMap)
|
||||
ctx = WithAuthorizationToken(ctx, token)
|
||||
i.setInstance(ctx)
|
||||
i.setOrganization(ctx)
|
||||
i.createMachineUserInstanceOwner(ctx, token)
|
||||
i.createMachineUserOrgOwner(ctx)
|
||||
i.createLoginClient(ctx)
|
||||
i.createMachineUserNoPermission(ctx)
|
||||
i.createWebAuthNClient()
|
||||
}
|
||||
|
||||
// Host returns the primary Domain of the instance with the port.
|
||||
func (i *Instance) Host() string {
|
||||
return fmt.Sprintf("%s:%d", i.Domain, i.Config.Port)
|
||||
}
|
||||
|
||||
func loadInstanceOwnerPAT() string {
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, adminPATFile))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(bytes.TrimSpace(data))
|
||||
}
|
||||
|
||||
func (i *Instance) createMachineUserInstanceOwner(ctx context.Context, token string) {
|
||||
mustAwait(func() error {
|
||||
user, err := i.Client.Auth.GetMyUser(WithAuthorizationToken(ctx, token), &auth.GetMyUserRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Users.Set(UserTypeIAMOwner, &User{
|
||||
ID: user.GetUser().GetId(),
|
||||
Username: user.GetUser().GetUserName(),
|
||||
Token: token,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) createMachineUserOrgOwner(ctx context.Context) {
|
||||
_, err := i.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{
|
||||
UserId: i.createMachineUser(ctx, UserTypeOrgOwner),
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Instance) createLoginClient(ctx context.Context) {
|
||||
_, err := i.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{
|
||||
UserId: i.createMachineUser(ctx, UserTypeLogin),
|
||||
Roles: []string{"IAM_LOGIN_CLIENT"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Instance) createMachineUserNoPermission(ctx context.Context) {
|
||||
i.createMachineUser(ctx, UserTypeNoPermission)
|
||||
}
|
||||
|
||||
func (i *Instance) setClient(ctx context.Context) {
|
||||
client, err := newClient(ctx, i.Host())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
i.Client = client
|
||||
}
|
||||
|
||||
func (i *Instance) setInstance(ctx context.Context) {
|
||||
mustAwait(func() error {
|
||||
instance, err := i.Client.Admin.GetMyInstance(ctx, &admin.GetMyInstanceRequest{})
|
||||
i.Instance = instance.GetInstance()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) setOrganization(ctx context.Context) {
|
||||
mustAwait(func() error {
|
||||
resp, err := i.Client.Mgmt.GetMyOrg(ctx, &management.GetMyOrgRequest{})
|
||||
i.DefaultOrg = resp.GetOrg()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) createMachineUser(ctx context.Context, userType UserType) (userID string) {
|
||||
mustAwait(func() error {
|
||||
username := gofakeit.Username()
|
||||
userResp, err := i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{
|
||||
UserName: username,
|
||||
Name: username,
|
||||
Description: userType.String(),
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID = userResp.GetUserId()
|
||||
patResp, err := i.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Users.Set(userType, &User{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
Token: patResp.GetToken(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return userID
|
||||
}
|
||||
|
||||
func (i *Instance) createWebAuthNClient() {
|
||||
i.WebAuthN = webauthn.NewClient(i.Config.WebAuthNName, i.Domain, http_util.BuildOrigin(i.Host(), i.Config.Secure))
|
||||
}
|
||||
|
||||
// Deprecated: WithAuthorization is misleading, as we have Zitadel resources called authorization now.
|
||||
// It is aliased to WithAuthorizationToken, which sets the Authorization header with a Bearer token.
|
||||
// Use WithAuthorizationToken directly instead.
|
||||
func (i *Instance) WithAuthorization(ctx context.Context, u UserType) context.Context {
|
||||
return i.WithAuthorizationToken(ctx, u)
|
||||
}
|
||||
|
||||
func (i *Instance) WithAuthorizationToken(ctx context.Context, u UserType) context.Context {
|
||||
return WithAuthorizationToken(ctx, i.Users.Get(u).Token)
|
||||
}
|
||||
|
||||
func (i *Instance) GetUserID(u UserType) string {
|
||||
return i.Users.Get(u).ID
|
||||
}
|
||||
|
||||
func WithAuthorizationToken(ctx context.Context, token string) context.Context {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
md = make(metadata.MD)
|
||||
}
|
||||
md.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
return metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
|
||||
func (i *Instance) BearerToken(ctx context.Context) string {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return md.Get("Authorization")[0]
|
||||
}
|
||||
|
||||
func (i *Instance) WithSystemAuthorizationHTTP(u UserType) map[string]string {
|
||||
return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", i.Users.Get(u).Token)}
|
||||
}
|
||||
|
||||
func await(af func() error) error {
|
||||
maxTimer := time.NewTimer(15 * time.Minute)
|
||||
for {
|
||||
err := af()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-maxTimer.C:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAwait(af func() error) {
|
||||
if err := await(af); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
454
apps/api/internal/integration/oidc.go
Normal file
454
apps/api/internal/integration/oidc.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rs"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/app"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/authn"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user"
|
||||
user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
func (i *Instance) CreateOIDCClientLoginVersion(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, loginVersion *app.LoginVersion, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) {
|
||||
if len(grantTypes) == 0 {
|
||||
grantTypes = []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN}
|
||||
}
|
||||
resp, err := i.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{
|
||||
ProjectId: projectID,
|
||||
Name: fmt.Sprintf("app-%d", time.Now().UnixNano()),
|
||||
RedirectUris: []string{redirectURI},
|
||||
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE},
|
||||
GrantTypes: grantTypes,
|
||||
AppType: appType,
|
||||
AuthMethodType: authMethod,
|
||||
PostLogoutRedirectUris: []string{logoutRedirectURI},
|
||||
Version: app.OIDCVersion_OIDC_VERSION_1_0,
|
||||
DevMode: devMode,
|
||||
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
|
||||
AccessTokenRoleAssertion: false,
|
||||
IdTokenRoleAssertion: false,
|
||||
IdTokenUserinfoAssertion: false,
|
||||
ClockSkew: nil,
|
||||
AdditionalOrigins: nil,
|
||||
SkipNativeAppSuccessPage: false,
|
||||
LoginVersion: loginVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, await(func() error {
|
||||
_, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{
|
||||
Id: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{
|
||||
ProjectId: projectID,
|
||||
AppId: resp.GetAppId(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, appType app.OIDCAppType, authMethod app.OIDCAuthMethodType, devMode bool, grantTypes ...app.OIDCGrantType) (*management.AddOIDCAppResponse, error) {
|
||||
return i.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, projectID, appType, authMethod, devMode, nil, grantTypes...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCNativeClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, devMode bool) (*management.AddOIDCAppResponse, error) {
|
||||
return i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, devMode)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCWebClientBasic(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) {
|
||||
return i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, false)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCWebClientJWT(ctx context.Context, redirectURI, logoutRedirectURI, projectID string, grantTypes ...app.OIDCGrantType) (client *management.AddOIDCAppResponse, keyData []byte, err error) {
|
||||
client, err = i.CreateOIDCClient(ctx, redirectURI, logoutRedirectURI, projectID, app.OIDCAppType_OIDC_APP_TYPE_WEB, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, false, grantTypes...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
key, err := i.Client.Mgmt.AddAppKey(ctx, &management.AddAppKeyRequest{
|
||||
ProjectId: projectID,
|
||||
AppId: client.GetAppId(),
|
||||
Type: authn.KeyType_KEY_TYPE_JSON,
|
||||
ExpirationDate: timestamppb.New(time.Now().Add(time.Hour)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mustAwait(func() error {
|
||||
_, err := i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{
|
||||
ProjectId: projectID,
|
||||
AppId: client.GetAppId(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
return client, key.GetKeyDetails(), nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCInactivateClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) {
|
||||
client, err := i.CreateOIDCNativeClient(ctx, redirectURI, logoutRedirectURI, projectID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = i.Client.Mgmt.DeactivateApp(ctx, &management.DeactivateAppRequest{
|
||||
ProjectId: projectID,
|
||||
AppId: client.GetAppId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCInactivateProjectClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) {
|
||||
client, err := i.CreateOIDCNativeClient(ctx, redirectURI, logoutRedirectURI, projectID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = i.Client.Mgmt.DeactivateProject(ctx, &management.DeactivateProjectRequest{
|
||||
Id: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, t *testing.T, redirectURI string, loginVersion *app.LoginVersion) (*management.AddOIDCAppResponse, error) {
|
||||
project := i.CreateProject(ctx, t, "", gofakeit.AppName(), false, false)
|
||||
|
||||
resp, err := i.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{
|
||||
ProjectId: project.GetId(),
|
||||
Name: fmt.Sprintf("app-%d", time.Now().UnixNano()),
|
||||
RedirectUris: []string{redirectURI},
|
||||
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN},
|
||||
GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT},
|
||||
AppType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT,
|
||||
AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
PostLogoutRedirectUris: nil,
|
||||
Version: app.OIDCVersion_OIDC_VERSION_1_0,
|
||||
DevMode: true,
|
||||
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
|
||||
AccessTokenRoleAssertion: false,
|
||||
IdTokenRoleAssertion: false,
|
||||
IdTokenUserinfoAssertion: false,
|
||||
ClockSkew: nil,
|
||||
AdditionalOrigins: nil,
|
||||
SkipNativeAppSuccessPage: false,
|
||||
LoginVersion: loginVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, await(func() error {
|
||||
_, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{
|
||||
Id: project.GetId(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{
|
||||
ProjectId: project.GetId(),
|
||||
AppId: resp.GetAppId(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCTokenExchangeClient(ctx context.Context, t *testing.T) (client *management.AddOIDCAppResponse, keyData []byte, err error) {
|
||||
project := i.CreateProject(ctx, t, "", gofakeit.AppName(), false, false)
|
||||
return i.CreateOIDCWebClientJWT(ctx, "", "", project.GetId(), app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) {
|
||||
return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{
|
||||
ProjectId: projectID,
|
||||
Name: fmt.Sprintf("api-%d", time.Now().UnixNano()),
|
||||
AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT,
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) CreateAPIClientBasic(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) {
|
||||
return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{
|
||||
ProjectId: projectID,
|
||||
Name: fmt.Sprintf("api-%d", time.Now().UnixNano()),
|
||||
AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC,
|
||||
})
|
||||
}
|
||||
|
||||
const CodeVerifier = "codeVerifier"
|
||||
|
||||
func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) {
|
||||
return i.CreateOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, loginClient, redirectURI, scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) {
|
||||
return i.createOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, redirectURI, "", loginBaseURI, scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) {
|
||||
return i.createOIDCAuthRequestWithDomain(ctx, domain, clientID, redirectURI, loginClient, "", scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) {
|
||||
provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, loginClient, scope...)
|
||||
if err != nil {
|
||||
return now, "", fmt.Errorf("create relying party: %w", err)
|
||||
}
|
||||
codeChallenge := oidc.NewSHACodeChallenge(CodeVerifier)
|
||||
authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge))
|
||||
|
||||
var headers map[string]string
|
||||
if loginClient != "" {
|
||||
headers = map[string]string{oidc_internal.LoginClientHeader: loginClient}
|
||||
}
|
||||
req, err := GetRequest(authURL, headers)
|
||||
if err != nil {
|
||||
return now, "", fmt.Errorf("get request: %w", err)
|
||||
}
|
||||
|
||||
now = time.Now()
|
||||
loc, err := CheckRedirect(req)
|
||||
if err != nil {
|
||||
return now, "", fmt.Errorf("check redirect: %w", err)
|
||||
}
|
||||
|
||||
if loginBaseURI == "" {
|
||||
loginBaseURI = provider.Issuer() + i.Config.LoginURLV2
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), loginBaseURI) {
|
||||
return now, "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String())
|
||||
}
|
||||
return now, strings.TrimPrefix(loc.String(), loginBaseURI), nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (authRequestID string, err error) {
|
||||
return i.createOIDCAuthRequestImplicit(ctx, clientID, redirectURI, nil, scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCAuthRequestImplicit(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) {
|
||||
return i.createOIDCAuthRequestImplicit(ctx, clientID, redirectURI, map[string]string{oidc_internal.LoginClientHeader: loginClient}, scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) createOIDCAuthRequestImplicit(ctx context.Context, clientID, redirectURI string, headers map[string]string, scope ...string) (authRequestID string, err error) {
|
||||
provider, err := i.CreateRelyingParty(ctx, clientID, redirectURI, scope...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
authURL := rp.AuthURL("state", provider)
|
||||
|
||||
// implicit is not natively supported so let's just overwrite the response type
|
||||
parsed, _ := url.Parse(authURL)
|
||||
queries := parsed.Query()
|
||||
queries.Set("response_type", string(oidc.ResponseTypeIDToken))
|
||||
parsed.RawQuery = queries.Encode()
|
||||
authURL = parsed.String()
|
||||
|
||||
req, err := GetRequest(authURL, headers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
loc, err := CheckRedirect(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
prefixWithHost := provider.Issuer() + i.Config.LoginURLV2
|
||||
if !strings.HasPrefix(loc.String(), prefixWithHost) {
|
||||
return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String())
|
||||
}
|
||||
return strings.TrimPrefix(loc.String(), prefixWithHost), nil
|
||||
}
|
||||
|
||||
func (i *Instance) OIDCIssuer() string {
|
||||
return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateRelyingParty(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) {
|
||||
return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, i.Users.Get(UserTypeLogin).Username, scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateRelyingPartyWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) {
|
||||
return i.CreateRelyingPartyForDomain(ctx, i.Domain, clientID, redirectURI, "", scope...)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateRelyingPartyForDomain(ctx context.Context, domain, clientID, redirectURI, loginClientUsername string, scope ...string) (rp.RelyingParty, error) {
|
||||
if len(scope) == 0 {
|
||||
scope = []string{oidc.ScopeOpenID}
|
||||
}
|
||||
if loginClientUsername == "" {
|
||||
return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, i.Config.Port, i.Config.Secure), clientID, "", redirectURI, scope)
|
||||
}
|
||||
loginClient := &http.Client{Transport: &loginRoundTripper{http.DefaultTransport, loginClientUsername}}
|
||||
return rp.NewRelyingPartyOIDC(ctx, http_util.BuildHTTP(domain, i.Config.Port, i.Config.Secure), clientID, "", redirectURI, scope, rp.WithHTTPClient(loginClient))
|
||||
}
|
||||
|
||||
type loginRoundTripper struct {
|
||||
http.RoundTripper
|
||||
loginUsername string
|
||||
}
|
||||
|
||||
func (c *loginRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set(oidc_internal.LoginClientHeader, c.loginUsername)
|
||||
return c.RoundTripper.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateResourceServerJWTProfile(ctx context.Context, keyFileData []byte) (rs.ResourceServer, error) {
|
||||
keyFile, err := client.ConfigFromKeyFileData(keyFileData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs.NewResourceServerJWTProfile(ctx, i.OIDCIssuer(), keyFile.ClientID, keyFile.KeyID, []byte(keyFile.Key))
|
||||
}
|
||||
|
||||
func (i *Instance) CreateResourceServerClientCredentials(ctx context.Context, clientID, clientSecret string) (rs.ResourceServer, error) {
|
||||
return rs.NewResourceServerClientCredentials(ctx, i.OIDCIssuer(), clientID, clientSecret)
|
||||
}
|
||||
|
||||
func GetRequest(url string, headers map[string]string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func CheckPost(url string, values url.Values) (*url.URL, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.PostForm(url, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.Location()
|
||||
}
|
||||
|
||||
func CheckRedirect(req *http.Request) (*url.URL, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("check redirect unexpected status: %q; body: %q", resp.Status, body)
|
||||
}
|
||||
|
||||
return resp.Location()
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCCredentialsClient(ctx context.Context) (machine *management.AddMachineUserResponse, name, clientID, clientSecret string, err error) {
|
||||
name = gofakeit.Username()
|
||||
machine, err = i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{
|
||||
Name: name,
|
||||
UserName: name,
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", "", err
|
||||
}
|
||||
secret, err := i.Client.Mgmt.GenerateMachineSecret(ctx, &management.GenerateMachineSecretRequest{
|
||||
UserId: machine.GetUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", "", err
|
||||
}
|
||||
return machine, name, secret.GetClientId(), secret.GetClientSecret(), nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCCredentialsClientInactive(ctx context.Context) (machine *management.AddMachineUserResponse, name, clientID, clientSecret string, err error) {
|
||||
name = gofakeit.Username()
|
||||
machine, err = i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{
|
||||
Name: name,
|
||||
UserName: name,
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", "", err
|
||||
}
|
||||
secret, err := i.Client.Mgmt.GenerateMachineSecret(ctx, &management.GenerateMachineSecretRequest{
|
||||
UserId: machine.GetUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", "", err
|
||||
}
|
||||
_, err = i.Client.UserV2.DeactivateUser(ctx, &user_v2.DeactivateUserRequest{
|
||||
UserId: machine.GetUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", "", err
|
||||
}
|
||||
return machine, name, secret.GetClientId(), secret.GetClientSecret(), nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context, keyLifetime time.Duration) (machine *management.AddMachineUserResponse, name string, keyData []byte, err error) {
|
||||
name = gofakeit.Username()
|
||||
machine, err = i.Client.Mgmt.AddMachineUser(ctx, &management.AddMachineUserRequest{
|
||||
Name: name,
|
||||
UserName: name,
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
keyResp, err := i.Client.Mgmt.AddMachineKey(ctx, &management.AddMachineKeyRequest{
|
||||
UserId: machine.GetUserId(),
|
||||
Type: authn.KeyType_KEY_TYPE_JSON,
|
||||
ExpirationDate: timestamppb.New(time.Now().Add(keyLifetime)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
mustAwait(func() error {
|
||||
_, err := i.Client.Mgmt.GetMachineKeyByIDs(ctx, &management.GetMachineKeyByIDsRequest{
|
||||
UserId: machine.GetUserId(),
|
||||
KeyId: keyResp.GetKeyId(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
return machine, name, keyResp.GetKeyDetails(), nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateDeviceAuthorizationRequest(ctx context.Context, clientID string, scopes ...string) (*oidc.DeviceAuthorizationResponse, error) {
|
||||
provider, err := i.CreateRelyingParty(ctx, clientID, "", scopes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rp.DeviceAuthorization(ctx, scopes, provider, nil)
|
||||
}
|
20
apps/api/internal/integration/rand.go
Normal file
20
apps/api/internal/integration/rand.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
func RandString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letterRunes[rand.Intn(len(letterRunes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
253
apps/api/internal/integration/saml.go
Normal file
253
apps/api/internal/integration/saml.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/crewjam/saml/samlsp"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/saml/pkg/provider"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
app_pb "github.com/zitadel/zitadel/pkg/grpc/app"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
|
||||
session_pb "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
)
|
||||
|
||||
const spCertificate = `-----BEGIN CERTIFICATE-----
|
||||
MIIDITCCAgmgAwIBAgIUUo5urYkuUHAe7LQ9sZSL+xXAqBwwDQYJKoZIhvcNAQEL
|
||||
BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIwNDEz
|
||||
MTE1MFoXDTI1MDEwMzEzMTE1MFowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w
|
||||
bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoACwbGIh8udK
|
||||
Um1r+yQoPtfswEX6Cb6Y1KwR6WZDYgzHdMyUC5Sy8Bg1H2puUZZukDLuyu6Pqvum
|
||||
8kfnzhjUR6nNCoUlidwE+yz020w5oOBofRKgJK/FVUuWD3k6kjdP9CrBFLG0PQQ3
|
||||
N2e4wilP4czCxizKero2a0e7Eq8OjHAPf8gjM+GWFZgVAbV8uf2Mjt1O2Vfbx5PZ
|
||||
sLuBZtl5jokx3NiC7my/yj81MbGEDPcQo0emeVBz3J3nVG6Yr4kdCKkvv2dhJ26C
|
||||
5cL7NIIUY4IQomJNwYC2NaYgSpQOxJHL/HsOPusO4Ia2WtUTXEZUFkxn1u0YuoSx
|
||||
CkGehF/1OwIDAQABo1MwUTAdBgNVHQ4EFgQUr6S0wA2l3MdfnvfveWDueQtaoJMw
|
||||
HwYDVR0jBBgwFoAUr6S0wA2l3MdfnvfveWDueQtaoJMwDwYDVR0TAQH/BAUwAwEB
|
||||
/zANBgkqhkiG9w0BAQsFAAOCAQEAH3Q9obyWJaMKFuGJDkIp1RFot79RWTVcAcwA
|
||||
XTJNfCseLONRIs4MkRxOn6GQBwV2IEqs1+hFG80dcd/c6yYyJ8bziKEyNMtPWrl6
|
||||
fdVD+1WnWcD1ZYrS8hgdz0FxXxl/+GjA8Pu6icmnhKgUDTYWns6Rj/gtQtZS8ZoA
|
||||
JY+T/1mGze2+Xx6pjuArZ7+hnH6EWwo+ckcmXAKyhnkhX7xIo1UFvNY2VWaGl2wU
|
||||
K2yyJA4Lu/NNmqPnpAcRDsnGP6r4frMhjnPq/ifC3B+6FT3p8dubV9PA0y86bAy5
|
||||
0yIgNje4DyWLy/DM9EpdPfJmvUAL6hOtyb8Aa9hR+a8stu7h6g==
|
||||
-----END CERTIFICATE-----`
|
||||
const spKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCgALBsYiHy50pS
|
||||
bWv7JCg+1+zARfoJvpjUrBHpZkNiDMd0zJQLlLLwGDUfam5Rlm6QMu7K7o+q+6by
|
||||
R+fOGNRHqc0KhSWJ3AT7LPTbTDmg4Gh9EqAkr8VVS5YPeTqSN0/0KsEUsbQ9BDc3
|
||||
Z7jCKU/hzMLGLMp6ujZrR7sSrw6McA9/yCMz4ZYVmBUBtXy5/YyO3U7ZV9vHk9mw
|
||||
u4Fm2XmOiTHc2ILubL/KPzUxsYQM9xCjR6Z5UHPcnedUbpiviR0IqS+/Z2EnboLl
|
||||
wvs0ghRjghCiYk3BgLY1piBKlA7Ekcv8ew4+6w7ghrZa1RNcRlQWTGfW7Ri6hLEK
|
||||
QZ6EX/U7AgMBAAECggEAD1aRkwpDO+BdORKhP9WDACc93F647fc1+mk2XFv/yKX1
|
||||
9uXnqUaLcsW3TfgrdCnKFouzZYPCBP+TzPUErTanHumRrNj/tLwBRDzWijE/8wKg
|
||||
MaE39dxdu+P/kiMqcLrZsMvqb3vrjc/aJTcNuJsyO7Cf2VSQ4nv4XIdnUQ60A9VR
|
||||
OmUp//VULZxImnPx/R304/p5VfOhyXfzBeoxUPogBurjtzkyXVG0EG2enJMMiTix
|
||||
900fTDez0TQ8V6O59vM04fhtPXvH51OkMTW/HU1QQvlnAJuX06I7k4CaBpF3xPII
|
||||
QpEbFILq5y6yAQJWELRGWzeoxK6kn6bNfI8S0+oKqQKBgQDg2UM7ruMASpY7B4zj
|
||||
XNztGDOx9BCdYyHH1O05r+ILmltBC7jFImwIYrHbaX+dg52l0PPImZuBz852IqrC
|
||||
VAEF30yBn2gWyVzIdo7W3mw9Jgqc4LrhStaJxOuXVoT2/PAuDBF8TJMNH9oLNqiD
|
||||
aPAI0cVn9BRV7AziEsrMlDLLiQKBgQC2K4Z/caAvwx/AescsN6lp+/m7MeLUpZzQ
|
||||
myZt44bnR5LouUo3vCYl+Bk8wu6PTd41LUYW/SW26HDDFTKgkBb1zVHfk5QRApaB
|
||||
VPwZnhcUvNapPOnDp75Qoq238wpfayQlKF1xCawS3N5AWkDaEdfzuH7umFJxVss2
|
||||
1tfDsn01owKBgAYWG3nMHBzv5+0lIS0uYFSSqSOSBbkc69cq7lj3Z9kEjp/OH2xG
|
||||
qEH52fKkgm3TGDta0p6Fee4jn+UWvySPfY+ZIcsIc5raTIaonuk2EBv/oZ3pf2WF
|
||||
zxTfnbj1AJhm9GFqtjZ1JC3gxNg03I7iEk1K0FsmAj7pKtgbxh2PjWhxAoGBAKBx
|
||||
BSwJbwOh3r0vZWvUOilV+0SbUyPmGI7Blr8BvTbFGuZNCsi7tP2L3O5e4Kzl7+b1
|
||||
0N0+Z5EIdwfaC5TOUup5wroeyDGTDesqZj5JthpVltnHBDuF6WArZsS0EVaojlUL
|
||||
kACWfC7AyB31X1iwjnng7CpHjZS01JWf8rgw44XxAoGAQ5YYd4WmGYZoJJak7zhb
|
||||
xnYG7hU7nS7pBPGob1FvjYMw1x/htuJCjxLh08dlzJGM6SFlDn7HVM9ou99w5n+d
|
||||
xtqmbthw2E9VjSk3zSYb4uFc6mv0C/kRPTDUFH+9CpQTBBx/O016hmcatxlBS6JL
|
||||
VAV6oE8sEJYHtR6YdZiMWWo=
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
func CreateSAMLSP(root string, idpMetadata *saml.EntityDescriptor, binding string) (*samlsp.Middleware, error) {
|
||||
rootURL, err := url.Parse(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPair, err := tls.X509KeyPair([]byte(spCertificate), []byte(spKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sp, err := samlsp.New(samlsp.Options{
|
||||
URL: *rootURL,
|
||||
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
|
||||
Certificate: keyPair.Leaf,
|
||||
IDPMetadata: idpMetadata,
|
||||
UseArtifactResponse: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.Binding = binding
|
||||
sp.ResponseBinding = binding
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (i *Instance) CreateSAMLClientLoginVersion(ctx context.Context, projectID string, m *samlsp.Middleware, loginVersion *app_pb.LoginVersion) (*management.AddSAMLAppResponse, error) {
|
||||
spMetadata, err := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.ResponseBinding == saml.HTTPRedirectBinding {
|
||||
metadata := strings.Replace(string(spMetadata), saml.HTTPPostBinding, saml.HTTPRedirectBinding, 2)
|
||||
spMetadata = []byte(metadata)
|
||||
}
|
||||
|
||||
resp, err := i.Client.Mgmt.AddSAMLApp(ctx, &management.AddSAMLAppRequest{
|
||||
ProjectId: projectID,
|
||||
Name: fmt.Sprintf("app-%s", gofakeit.AppName()),
|
||||
Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata},
|
||||
LoginVersion: loginVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, await(func() error {
|
||||
_, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{
|
||||
Id: projectID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{
|
||||
ProjectId: projectID,
|
||||
AppId: resp.GetAppId(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) {
|
||||
return i.CreateSAMLClientLoginVersion(ctx, projectID, m, nil)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateSAMLAuthRequestWithoutLoginClientHeader(m *samlsp.Middleware, loginBaseURI string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) {
|
||||
return i.createSAMLAuthRequest(m, "", loginBaseURI, acs, relayState, responseBinding)
|
||||
}
|
||||
|
||||
func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) {
|
||||
return i.createSAMLAuthRequest(m, loginClient, "", acs, relayState, responseBinding)
|
||||
}
|
||||
|
||||
func (i *Instance) createSAMLAuthRequest(m *samlsp.Middleware, loginClient, loginBaseURI string, acs saml.Endpoint, relayState, responseBinding string) (now time.Time, authRequestID string, err error) {
|
||||
authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding)
|
||||
if err != nil {
|
||||
return now, "", err
|
||||
}
|
||||
|
||||
redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider)
|
||||
if err != nil {
|
||||
return now, "", err
|
||||
}
|
||||
|
||||
var headers map[string]string
|
||||
if loginClient != "" {
|
||||
headers = map[string]string{oidc_internal.LoginClientHeader: loginClient}
|
||||
}
|
||||
req, err := GetRequest(redirectURL.String(), headers)
|
||||
if err != nil {
|
||||
return now, "", fmt.Errorf("get request: %w", err)
|
||||
}
|
||||
|
||||
now = time.Now()
|
||||
loc, err := CheckRedirect(req)
|
||||
if err != nil {
|
||||
return now, "", fmt.Errorf("check redirect: %w", err)
|
||||
}
|
||||
|
||||
if loginBaseURI == "" {
|
||||
loginBaseURI = i.Issuer() + i.Config.LoginURLV2
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), loginBaseURI) {
|
||||
return now, "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String())
|
||||
}
|
||||
return now, strings.TrimPrefix(loc.String(), loginBaseURI), nil
|
||||
}
|
||||
|
||||
func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse {
|
||||
resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{
|
||||
SamlRequestId: id,
|
||||
ResponseKind: &saml_pb.CreateResponseRequest_Error{Error: &saml_pb.AuthorizationError{Error: reason}},
|
||||
})
|
||||
logging.OnError(err).Panic("create human user")
|
||||
return resp
|
||||
}
|
||||
|
||||
func (i *Instance) SuccessfulSAMLAuthRequest(ctx context.Context, userId, id string) *saml_pb.CreateResponseResponse {
|
||||
respSession, err := i.Client.SessionV2.CreateSession(ctx, &session_pb.CreateSessionRequest{
|
||||
Checks: &session_pb.Checks{
|
||||
User: &session_pb.CheckUser{
|
||||
Search: &session_pb.CheckUser_UserId{
|
||||
UserId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
logging.OnError(err).Panic("create session")
|
||||
|
||||
resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{
|
||||
SamlRequestId: id,
|
||||
ResponseKind: &saml_pb.CreateResponseRequest_Session{
|
||||
Session: &saml_pb.Session{
|
||||
SessionId: respSession.GetSessionId(),
|
||||
SessionToken: respSession.GetSessionToken(),
|
||||
},
|
||||
},
|
||||
})
|
||||
logging.OnError(err).Panic("create human user")
|
||||
return resp
|
||||
}
|
||||
|
||||
func (i *Instance) GetSAMLIDPMetadata() (*saml.EntityDescriptor, error) {
|
||||
issuer := i.Issuer() + "/saml/v2"
|
||||
idpEntityID := issuer + "/metadata"
|
||||
|
||||
req, err := http.NewRequestWithContext(provider.ContextWithIssuer(context.Background(), issuer), http.MethodGet, idpEntityID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entityDescriptor := new(saml.EntityDescriptor)
|
||||
if err := xml.Unmarshal(data, entityDescriptor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entityDescriptor, nil
|
||||
}
|
||||
|
||||
func (i *Instance) Issuer() string {
|
||||
return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure)
|
||||
}
|
21
apps/api/internal/integration/scim/assertions.go
Normal file
21
apps/api/internal/integration/scim/assertions.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type AssertedScimError struct {
|
||||
Error *ScimError
|
||||
}
|
||||
|
||||
func RequireScimError(t require.TestingT, httpStatus int, err error) AssertedScimError {
|
||||
require.Error(t, err)
|
||||
|
||||
var scimErr *ScimError
|
||||
require.True(t, errors.As(err, &scimErr))
|
||||
require.Equal(t, strconv.Itoa(httpStatus), scimErr.Status)
|
||||
return AssertedScimError{scimErr} // wrap it, otherwise error handling is enforced
|
||||
}
|
342
apps/api/internal/integration/scim/client.go
Normal file
342
apps/api/internal/integration/scim/client.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package scim
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
zhttp "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/middleware"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/resources"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
Users *ResourceClient[resources.ScimUser]
|
||||
}
|
||||
|
||||
type ResourceClient[T any] struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
resourceName string
|
||||
}
|
||||
|
||||
type ScimError struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
ScimType string `json:"scimType"`
|
||||
Detail string `json:"detail"`
|
||||
Status string `json:"status"`
|
||||
ZitadelDetail *ZitadelErrorDetail `json:"urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail,omitempty"`
|
||||
}
|
||||
|
||||
type ZitadelErrorDetail struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
|
||||
Count *int `json:"count,omitempty"`
|
||||
|
||||
// StartIndex An integer indicating the 1-based index of the first query result.
|
||||
StartIndex *int `json:"startIndex,omitempty"`
|
||||
|
||||
// Filter a scim filter expression to filter the query result.
|
||||
Filter *string `json:"filter,omitempty"`
|
||||
|
||||
SortBy *string `json:"sortBy,omitempty"`
|
||||
SortOrder *ListRequestSortOrder `json:"sortOrder,omitempty"`
|
||||
|
||||
SendAsPost bool
|
||||
}
|
||||
|
||||
type ListRequestSortOrder string
|
||||
|
||||
const (
|
||||
ListRequestSortOrderAsc ListRequestSortOrder = "ascending"
|
||||
ListRequestSortOrderDsc ListRequestSortOrder = "descending"
|
||||
)
|
||||
|
||||
type ListResponse[T any] struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
ItemsPerPage int `json:"itemsPerPage"`
|
||||
TotalResults int `json:"totalResults"`
|
||||
StartIndex int `json:"startIndex"`
|
||||
Resources []T `json:"Resources"`
|
||||
}
|
||||
|
||||
type BulkRequest struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
FailOnErrors *int `json:"failOnErrors"`
|
||||
Operations []*BulkRequestOperation `json:"Operations"`
|
||||
}
|
||||
|
||||
type BulkRequestOperation struct {
|
||||
Method string `json:"method"`
|
||||
BulkID string `json:"bulkId"`
|
||||
Path string `json:"path"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type BulkResponse struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
Operations []*BulkResponseOperation `json:"Operations"`
|
||||
}
|
||||
|
||||
type BulkResponseOperation struct {
|
||||
Method string `json:"method"`
|
||||
BulkID string `json:"bulkId,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Response *ScimError `json:"response,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const (
|
||||
listQueryParamSortBy = "sortBy"
|
||||
listQueryParamSortOrder = "sortOrder"
|
||||
listQueryParamCount = "count"
|
||||
listQueryParamStartIndex = "startIndex"
|
||||
listQueryParamFilter = "filter"
|
||||
)
|
||||
|
||||
func NewScimClient(target string) *Client {
|
||||
target = "http://" + target + schemas.HandlerPrefix
|
||||
client := &http.Client{}
|
||||
return &Client{
|
||||
client: client,
|
||||
baseURL: target,
|
||||
Users: &ResourceClient[resources.ScimUser]{
|
||||
client: client,
|
||||
baseURL: target,
|
||||
resourceName: "Users",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetServiceProviderConfig(ctx context.Context, orgID string) ([]byte, error) {
|
||||
return c.getWithRawResponse(ctx, orgID, "/ServiceProviderConfig")
|
||||
}
|
||||
|
||||
func (c *Client) GetSchemas(ctx context.Context, orgID string) ([]byte, error) {
|
||||
return c.getWithRawResponse(ctx, orgID, "/Schemas")
|
||||
}
|
||||
|
||||
func (c *Client) GetSchema(ctx context.Context, orgID, schemaID string) ([]byte, error) {
|
||||
return c.getWithRawResponse(ctx, orgID, "/Schemas/"+schemaID)
|
||||
}
|
||||
|
||||
func (c *Client) GetResourceTypes(ctx context.Context, orgID string) ([]byte, error) {
|
||||
return c.getWithRawResponse(ctx, orgID, "/ResourceTypes")
|
||||
}
|
||||
|
||||
func (c *Client) GetResourceType(ctx context.Context, orgID, name string) ([]byte, error) {
|
||||
return c.getWithRawResponse(ctx, orgID, "/ResourceTypes/"+name)
|
||||
}
|
||||
|
||||
func (c *Client) Bulk(ctx context.Context, orgID string, body []byte) (*BulkResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+orgID+"/Bulk", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||
resp := new(BulkResponse)
|
||||
return resp, doReq(c.client, req, resp)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) Create(ctx context.Context, orgID string, body []byte) (*T, error) {
|
||||
return c.doWithBody(ctx, http.MethodPost, orgID, "", bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) Replace(ctx context.Context, orgID, id string, body []byte) (*T, error) {
|
||||
return c.doWithBody(ctx, http.MethodPut, orgID, id, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) Update(ctx context.Context, orgID, id string, body []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.buildResourceURL(orgID, id), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return doReq(c.client, req, nil)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) List(ctx context.Context, orgID string, req *ListRequest) (*ListResponse[*T], error) {
|
||||
listResponse := new(ListResponse[*T])
|
||||
|
||||
if req.SendAsPost {
|
||||
listReq, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = c.doWithResponse(ctx, http.MethodPost, orgID, ".search", bytes.NewReader(listReq), listResponse)
|
||||
return listResponse, err
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.SortBy != nil {
|
||||
query.Set(listQueryParamSortBy, *req.SortBy)
|
||||
}
|
||||
|
||||
if req.SortOrder != nil {
|
||||
query.Set(listQueryParamSortOrder, string(*req.SortOrder))
|
||||
}
|
||||
|
||||
if req.Count != nil {
|
||||
query.Set(listQueryParamCount, strconv.Itoa(*req.Count))
|
||||
}
|
||||
|
||||
if req.StartIndex != nil {
|
||||
query.Set(listQueryParamStartIndex, strconv.Itoa(*req.StartIndex))
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
query.Set(listQueryParamFilter, *req.Filter)
|
||||
}
|
||||
|
||||
err = c.doWithResponse(ctx, http.MethodGet, orgID, "?"+query.Encode(), nil, listResponse)
|
||||
return listResponse, err
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) Get(ctx context.Context, orgID, resourceID string) (*T, error) {
|
||||
return c.doWithBody(ctx, http.MethodGet, orgID, resourceID, nil)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) Delete(ctx context.Context, orgID, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, orgID, id)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) do(ctx context.Context, method, orgID, url string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return doReq(c.client, req, nil)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) doWithResponse(ctx context.Context, method, orgID, url string, body io.Reader, response interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||
return doReq(c.client, req, response)
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) doWithBody(ctx context.Context, method, orgID, url string, body io.Reader) (*T, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.buildResourceURL(orgID, url), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(zhttp.ContentType, middleware.ContentTypeScim)
|
||||
responseEntity := new(T)
|
||||
return responseEntity, doReq(c.client, req, responseEntity)
|
||||
}
|
||||
|
||||
func (c *Client) getWithRawResponse(ctx context.Context, orgID, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/"+orgID+url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
logging.OnError(err).Error("Failed to close response body")
|
||||
}()
|
||||
|
||||
if (resp.StatusCode / 100) != 2 {
|
||||
return nil, readScimError(resp)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func doReq(client *http.Client, req *http.Request, responseEntity interface{}) error {
|
||||
addTokenAsHeader(req)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
logging.OnError(err).Error("Failed to close response body")
|
||||
}()
|
||||
|
||||
if (resp.StatusCode / 100) != 2 {
|
||||
return readScimError(resp)
|
||||
}
|
||||
|
||||
if responseEntity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = readJson(responseEntity, resp)
|
||||
return err
|
||||
}
|
||||
|
||||
func addTokenAsHeader(req *http.Request) {
|
||||
md, ok := metadata.FromOutgoingContext(req.Context())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", md.Get("Authorization")[0])
|
||||
}
|
||||
|
||||
func readJson(entity interface{}, resp *http.Response) error {
|
||||
defer func(body io.ReadCloser) {
|
||||
err := body.Close()
|
||||
logging.OnError(err).Panic("Failed to close response body")
|
||||
}(resp.Body)
|
||||
|
||||
err := json.NewDecoder(resp.Body).Decode(entity)
|
||||
logging.OnError(err).Panic("Failed decoding entity")
|
||||
return err
|
||||
}
|
||||
|
||||
func readScimError(resp *http.Response) error {
|
||||
scimErr := new(ScimError)
|
||||
readErr := readJson(scimErr, resp)
|
||||
logging.OnError(readErr).Panic("Failed reading scim error")
|
||||
return scimErr
|
||||
}
|
||||
|
||||
func (c *ResourceClient[T]) buildResourceURL(orgID, segment string) string {
|
||||
if segment == "" || strings.HasPrefix(segment, "?") {
|
||||
return c.baseURL + "/" + path.Join(orgID, c.resourceName) + segment
|
||||
}
|
||||
|
||||
return c.baseURL + "/" + path.Join(orgID, c.resourceName, segment)
|
||||
}
|
||||
|
||||
func (err *ScimError) Error() string {
|
||||
return "scim error: " + err.Detail
|
||||
}
|
9
apps/api/internal/integration/sink/channel.go
Normal file
9
apps/api/internal/integration/sink/channel.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package sink
|
||||
|
||||
//go:generate enumer -type Channel -trimprefix Channel -transform snake
|
||||
type Channel int
|
||||
|
||||
const (
|
||||
ChannelMilestone Channel = iota
|
||||
ChannelQuota
|
||||
)
|
78
apps/api/internal/integration/sink/channel_enumer.go
Normal file
78
apps/api/internal/integration/sink/channel_enumer.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Code generated by "enumer -type Channel -trimprefix Channel -transform snake"; DO NOT EDIT.
|
||||
|
||||
package sink
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const _ChannelName = "milestonequota"
|
||||
|
||||
var _ChannelIndex = [...]uint8{0, 9, 14}
|
||||
|
||||
const _ChannelLowerName = "milestonequota"
|
||||
|
||||
func (i Channel) String() string {
|
||||
if i < 0 || i >= Channel(len(_ChannelIndex)-1) {
|
||||
return fmt.Sprintf("Channel(%d)", i)
|
||||
}
|
||||
return _ChannelName[_ChannelIndex[i]:_ChannelIndex[i+1]]
|
||||
}
|
||||
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
func _ChannelNoOp() {
|
||||
var x [1]struct{}
|
||||
_ = x[ChannelMilestone-(0)]
|
||||
_ = x[ChannelQuota-(1)]
|
||||
}
|
||||
|
||||
var _ChannelValues = []Channel{ChannelMilestone, ChannelQuota}
|
||||
|
||||
var _ChannelNameToValueMap = map[string]Channel{
|
||||
_ChannelName[0:9]: ChannelMilestone,
|
||||
_ChannelLowerName[0:9]: ChannelMilestone,
|
||||
_ChannelName[9:14]: ChannelQuota,
|
||||
_ChannelLowerName[9:14]: ChannelQuota,
|
||||
}
|
||||
|
||||
var _ChannelNames = []string{
|
||||
_ChannelName[0:9],
|
||||
_ChannelName[9:14],
|
||||
}
|
||||
|
||||
// ChannelString retrieves an enum value from the enum constants string name.
|
||||
// Throws an error if the param is not part of the enum.
|
||||
func ChannelString(s string) (Channel, error) {
|
||||
if val, ok := _ChannelNameToValueMap[s]; ok {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
if val, ok := _ChannelNameToValueMap[strings.ToLower(s)]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%s does not belong to Channel values", s)
|
||||
}
|
||||
|
||||
// ChannelValues returns all values of the enum
|
||||
func ChannelValues() []Channel {
|
||||
return _ChannelValues
|
||||
}
|
||||
|
||||
// ChannelStrings returns a slice of all String values of the enum
|
||||
func ChannelStrings() []string {
|
||||
strs := make([]string, len(_ChannelNames))
|
||||
copy(strs, _ChannelNames)
|
||||
return strs
|
||||
}
|
||||
|
||||
// IsAChannel returns "true" if the value is listed in the enum definition. "false" otherwise
|
||||
func (i Channel) IsAChannel() bool {
|
||||
for _, v := range _ChannelValues {
|
||||
if i == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
551
apps/api/internal/integration/sink/server.go
Normal file
551
apps/api/internal/integration/sink/server.go
Normal file
@@ -0,0 +1,551 @@
|
||||
//go:build integration
|
||||
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
crewjam_saml "github.com/crewjam/saml"
|
||||
"github.com/go-chi/chi/v5"
|
||||
goldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/idp/providers/jwt"
|
||||
"github.com/zitadel/zitadel/internal/idp/providers/ldap"
|
||||
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
|
||||
openid "github.com/zitadel/zitadel/internal/idp/providers/oidc"
|
||||
"github.com/zitadel/zitadel/internal/idp/providers/saml"
|
||||
)
|
||||
|
||||
const (
|
||||
port = "8081"
|
||||
listenAddr = "127.0.0.1:" + port
|
||||
host = "localhost:" + port
|
||||
)
|
||||
|
||||
// CallURL returns the full URL to the handler of a [Channel].
|
||||
func CallURL(ch Channel) string {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: rootPath(ch),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: successfulIntentOAuthPath(),
|
||||
}
|
||||
resp, err := callIntent(u.String(), &SuccessfulIntentRequest{
|
||||
InstanceID: instanceID,
|
||||
IDPID: idpID,
|
||||
IDPUserID: idpUserID,
|
||||
UserID: userID,
|
||||
Expiry: expiry,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, uint64(0), err
|
||||
}
|
||||
return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil
|
||||
}
|
||||
|
||||
func SuccessfulOIDCIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: successfulIntentOIDCPath(),
|
||||
}
|
||||
resp, err := callIntent(u.String(), &SuccessfulIntentRequest{
|
||||
InstanceID: instanceID,
|
||||
IDPID: idpID,
|
||||
IDPUserID: idpUserID,
|
||||
UserID: userID,
|
||||
Expiry: expiry,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, uint64(0), err
|
||||
}
|
||||
return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil
|
||||
}
|
||||
|
||||
func SuccessfulSAMLIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: successfulIntentSAMLPath(),
|
||||
}
|
||||
resp, err := callIntent(u.String(), &SuccessfulIntentRequest{
|
||||
InstanceID: instanceID,
|
||||
IDPID: idpID,
|
||||
IDPUserID: idpUserID,
|
||||
UserID: userID,
|
||||
Expiry: expiry,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, uint64(0), err
|
||||
}
|
||||
return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil
|
||||
}
|
||||
|
||||
func SuccessfulLDAPIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: successfulIntentLDAPPath(),
|
||||
}
|
||||
resp, err := callIntent(u.String(), &SuccessfulIntentRequest{
|
||||
InstanceID: instanceID,
|
||||
IDPID: idpID,
|
||||
IDPUserID: idpUserID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, uint64(0), err
|
||||
}
|
||||
return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil
|
||||
}
|
||||
|
||||
func SuccessfulJWTIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) {
|
||||
u := url.URL{
|
||||
Scheme: "http",
|
||||
Host: host,
|
||||
Path: successfulIntentJWTPath(),
|
||||
}
|
||||
resp, err := callIntent(u.String(), &SuccessfulIntentRequest{
|
||||
InstanceID: instanceID,
|
||||
IDPID: idpID,
|
||||
IDPUserID: idpUserID,
|
||||
UserID: userID,
|
||||
Expiry: expiry,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, uint64(0), err
|
||||
}
|
||||
return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil
|
||||
}
|
||||
|
||||
// StartServer starts a simple HTTP server on localhost:8081
|
||||
// ZITADEL can use the server to send HTTP requests which can be
|
||||
// used to validate tests through [Subscribe]rs.
|
||||
// For each [Channel] a route is registered on http://localhost:8081/<channel_name>.
|
||||
// The route must be used to send the HTTP request to be validated.
|
||||
// [CallURL] can be used to obtain the full URL for a given Channel.
|
||||
//
|
||||
// This function is only active when the `integration` build tag is enabled
|
||||
func StartServer(commands *command.Commands) (close func()) {
|
||||
router := chi.NewRouter()
|
||||
for _, ch := range ChannelValues() {
|
||||
fwd := &forwarder{
|
||||
channelID: ch,
|
||||
subscribers: make(map[int64]chan<- *Request),
|
||||
}
|
||||
router.HandleFunc(rootPath(ch), fwd.receiveHandler)
|
||||
router.HandleFunc(subscribePath(ch), fwd.subscriptionHandler)
|
||||
router.HandleFunc(successfulIntentOAuthPath(), successfulIntentHandler(commands, createSuccessfulOAuthIntent))
|
||||
router.HandleFunc(successfulIntentOIDCPath(), successfulIntentHandler(commands, createSuccessfulOIDCIntent))
|
||||
router.HandleFunc(successfulIntentSAMLPath(), successfulIntentHandler(commands, createSuccessfulSAMLIntent))
|
||||
router.HandleFunc(successfulIntentLDAPPath(), successfulIntentHandler(commands, createSuccessfulLDAPIntent))
|
||||
router.HandleFunc(successfulIntentJWTPath(), successfulIntentHandler(commands, createSuccessfulJWTIntent))
|
||||
}
|
||||
s := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
logging.WithFields("listen_addr", listenAddr).Warn("!!!! A sink server is started which may expose sensitive data on a public endpoint. Make sure the `integration` build tag is disabled for production builds. !!!!")
|
||||
go func() {
|
||||
err := s.ListenAndServe()
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
logging.WithError(err).Fatal("sink server")
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
logging.OnError(s.Close()).Error("sink server")
|
||||
}
|
||||
}
|
||||
|
||||
func rootPath(c Channel) string {
|
||||
return path.Join("/", c.String())
|
||||
}
|
||||
|
||||
func subscribePath(c Channel) string {
|
||||
return path.Join("/", c.String(), "subscribe")
|
||||
}
|
||||
|
||||
func intentPath() string {
|
||||
return path.Join("/", "intent")
|
||||
}
|
||||
|
||||
func successfulIntentPath() string {
|
||||
return path.Join(intentPath(), "/", "successful")
|
||||
}
|
||||
|
||||
func successfulIntentOAuthPath() string {
|
||||
return path.Join(successfulIntentPath(), "/", "oauth")
|
||||
}
|
||||
|
||||
func successfulIntentOIDCPath() string {
|
||||
return path.Join(successfulIntentPath(), "/", "oidc")
|
||||
}
|
||||
|
||||
func successfulIntentSAMLPath() string {
|
||||
return path.Join(successfulIntentPath(), "/", "saml")
|
||||
}
|
||||
|
||||
func successfulIntentLDAPPath() string {
|
||||
return path.Join(successfulIntentPath(), "/", "ldap")
|
||||
}
|
||||
|
||||
func successfulIntentJWTPath() string {
|
||||
return path.Join(successfulIntentPath(), "/", "jwt")
|
||||
}
|
||||
|
||||
// forwarder handles incoming HTTP requests from ZITADEL and
|
||||
// forwards them to all subscribed web sockets.
|
||||
type forwarder struct {
|
||||
channelID Channel
|
||||
id atomic.Int64
|
||||
mtx sync.RWMutex
|
||||
subscribers map[int64]chan<- *Request
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// receiveHandler receives a simple HTTP for a single [Channel]
|
||||
// and forwards them on all active subscribers of that Channel.
|
||||
func (c *forwarder) receiveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
req := &Request{
|
||||
Header: r.Header.Clone(),
|
||||
}
|
||||
var err error
|
||||
req.Body, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
c.mtx.RLock()
|
||||
for _, reqChan := range c.subscribers {
|
||||
reqChan <- req
|
||||
}
|
||||
c.mtx.RUnlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// subscriptionHandler upgrades HTTP request to a websocket connection for subscribers.
|
||||
// All received HTTP requests on a subscriber's channel are send on the websocket to the client.
|
||||
func (c *forwarder) subscriptionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := c.upgrader.Upgrade(w, r, nil)
|
||||
logging.OnError(err).Error("websocket upgrade")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
done := readLoop(ws)
|
||||
|
||||
id := c.id.Add(1)
|
||||
reqChannel := make(chan *Request, 100)
|
||||
|
||||
c.mtx.Lock()
|
||||
c.subscribers[id] = reqChannel
|
||||
c.mtx.Unlock()
|
||||
|
||||
logging.WithFields("id", id, "channel", c.channelID).Info("websocket opened")
|
||||
|
||||
defer func() {
|
||||
c.mtx.Lock()
|
||||
delete(c.subscribers, id)
|
||||
c.mtx.Unlock()
|
||||
|
||||
ws.Close()
|
||||
close(reqChannel)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
logging.WithError(err).WithFields(logrus.Fields{"id": id, "channel": c.channelID}).Info("websocket closed")
|
||||
return
|
||||
case req := <-reqChannel:
|
||||
if err := ws.WriteJSON(req); err != nil {
|
||||
logging.WithError(err).WithFields(logrus.Fields{"id": id, "channel": c.channelID}).Error("websocket write json")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop makes sure we can receive close messages
|
||||
func readLoop(ws *websocket.Conn) (done chan error) {
|
||||
done = make(chan error, 1)
|
||||
|
||||
go func(done chan<- error) {
|
||||
for {
|
||||
_, _, err := ws.NextReader()
|
||||
if err != nil {
|
||||
done <- err
|
||||
break
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}(done)
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
type SuccessfulIntentRequest struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
IDPID string `json:"idp_id"`
|
||||
IDPUserID string `json:"idp_user_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
type SuccessfulIntentResponse struct {
|
||||
IntentID string `json:"intent_id"`
|
||||
Token string `json:"token"`
|
||||
ChangeDate time.Time `json:"change_date"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
}
|
||||
|
||||
func callIntent(url string, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(url, "application/json", io.NopCloser(bytes.NewReader(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(string(body))
|
||||
}
|
||||
result := new(SuccessfulIntentResponse)
|
||||
if err := json.Unmarshal(body, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func successfulIntentHandler(cmd *command.Commands, createIntent func(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error)) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req := &SuccessfulIntentRequest{}
|
||||
if err := json.Unmarshal(body, req); err != nil {
|
||||
}
|
||||
|
||||
ctx := authz.WithInstanceID(r.Context(), req.InstanceID)
|
||||
resp, err := createIntent(ctx, cmd, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func createIntent(ctx context.Context, cmd *command.Commands, instanceID, idpID string) (string, error) {
|
||||
writeModel, _, err := cmd.CreateIntent(ctx, "", idpID, "https://example.com/success", "https://example.com/failure", instanceID, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return writeModel.AggregateID, nil
|
||||
}
|
||||
|
||||
func createSuccessfulOAuthIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idAttribute := "id"
|
||||
idpUser := oauth.NewUserMapper(idAttribute)
|
||||
idpUser.RawInfo = map[string]interface{}{
|
||||
idAttribute: req.IDPUserID,
|
||||
"preferred_username": "username",
|
||||
}
|
||||
idpSession := &oauth.Session{
|
||||
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: "accessToken",
|
||||
Expiry: req.Expiry,
|
||||
},
|
||||
IDToken: "idToken",
|
||||
},
|
||||
}
|
||||
token, err := cmd.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SuccessfulIntentResponse{
|
||||
intentID,
|
||||
token,
|
||||
writeModel.ChangeDate,
|
||||
writeModel.ProcessedSequence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createSuccessfulOIDCIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID)
|
||||
writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID)
|
||||
idpUser := openid.NewUser(
|
||||
&oidc.UserInfo{
|
||||
Subject: req.IDPUserID,
|
||||
UserInfoProfile: oidc.UserInfoProfile{
|
||||
PreferredUsername: "username",
|
||||
},
|
||||
},
|
||||
)
|
||||
idpSession := &openid.Session{
|
||||
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: "accessToken",
|
||||
Expiry: req.Expiry,
|
||||
},
|
||||
IDToken: "idToken",
|
||||
},
|
||||
}
|
||||
token, err := cmd.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SuccessfulIntentResponse{
|
||||
intentID,
|
||||
token,
|
||||
writeModel.ChangeDate,
|
||||
writeModel.ProcessedSequence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createSuccessfulSAMLIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID)
|
||||
writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID)
|
||||
|
||||
idpUser := &saml.UserMapper{
|
||||
ID: req.IDPUserID,
|
||||
Attributes: map[string][]string{"attribute1": {"value1"}},
|
||||
}
|
||||
session := &saml.Session{
|
||||
Assertion: &crewjam_saml.Assertion{
|
||||
ID: "id",
|
||||
Conditions: &crewjam_saml.Conditions{
|
||||
NotOnOrAfter: req.Expiry,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
token, err := cmd.SucceedSAMLIDPIntent(ctx, writeModel, idpUser, req.UserID, session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SuccessfulIntentResponse{
|
||||
intentID,
|
||||
token,
|
||||
writeModel.ChangeDate,
|
||||
writeModel.ProcessedSequence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createSuccessfulLDAPIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID)
|
||||
writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID)
|
||||
username := "username"
|
||||
lang := language.Make("en")
|
||||
idpUser := ldap.NewUser(
|
||||
req.IDPUserID,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
username,
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
false,
|
||||
lang,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
session := &ldap.Session{Entry: &goldap.Entry{
|
||||
Attributes: []*goldap.EntryAttribute{
|
||||
{Name: "id", Values: []string{req.IDPUserID}},
|
||||
{Name: "username", Values: []string{username}},
|
||||
{Name: "language", Values: []string{lang.String()}},
|
||||
},
|
||||
}}
|
||||
token, err := cmd.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, req.UserID, session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SuccessfulIntentResponse{
|
||||
intentID,
|
||||
token,
|
||||
writeModel.ChangeDate,
|
||||
writeModel.ProcessedSequence,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createSuccessfulJWTIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) {
|
||||
intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID)
|
||||
writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID)
|
||||
idpUser := &jwt.User{
|
||||
IDTokenClaims: &oidc.IDTokenClaims{
|
||||
TokenClaims: oidc.TokenClaims{
|
||||
Subject: req.IDPUserID,
|
||||
},
|
||||
},
|
||||
}
|
||||
session := &jwt.Session{
|
||||
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
IDToken: "idToken",
|
||||
},
|
||||
}
|
||||
token, err := cmd.SucceedIDPIntent(ctx, writeModel, idpUser, session, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SuccessfulIntentResponse{
|
||||
intentID,
|
||||
token,
|
||||
writeModel.ChangeDate,
|
||||
writeModel.ProcessedSequence,
|
||||
}, nil
|
||||
}
|
4
apps/api/internal/integration/sink/sink.go
Normal file
4
apps/api/internal/integration/sink/sink.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package sink provides a simple HTTP server where Zitadel can send HTTP based messages,
|
||||
// which are then possible to be observed using observers on websockets.
|
||||
// The contents of this package become available when the `integration` build tag is enabled.
|
||||
package sink
|
11
apps/api/internal/integration/sink/stub.go
Normal file
11
apps/api/internal/integration/sink/stub.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !integration
|
||||
|
||||
package sink
|
||||
|
||||
import "github.com/zitadel/zitadel/internal/command"
|
||||
|
||||
// StartServer and its returned close function are a no-op
|
||||
// when the `integration` build tag is disabled.
|
||||
func StartServer(cmd *command.Commands) (close func()) {
|
||||
return func() {}
|
||||
}
|
90
apps/api/internal/integration/sink/subscription.go
Normal file
90
apps/api/internal/integration/sink/subscription.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build integration
|
||||
|
||||
package sink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/zitadel/logging"
|
||||
)
|
||||
|
||||
// Request is a message forwarded from the handler to [Subscription]s.
|
||||
type Request struct {
|
||||
Header http.Header
|
||||
Body json.RawMessage
|
||||
}
|
||||
|
||||
// Subscription is a websocket client to which [Request]s are forwarded by the server.
|
||||
type Subscription struct {
|
||||
conn *websocket.Conn
|
||||
closed atomic.Bool
|
||||
reqChannel chan *Request
|
||||
}
|
||||
|
||||
// Subscribe to a channel.
|
||||
// The subscription forwards all requests it received on the channel's
|
||||
// handler, after Subscribe has returned.
|
||||
// Multiple subscription may be active on a single channel.
|
||||
// Each request is always forwarded to each Subscription.
|
||||
// Close must be called to cleanup up the Subscription's channel and go routine.
|
||||
func Subscribe(ctx context.Context, ch Channel) *Subscription {
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: listenAddr,
|
||||
Path: subscribePath(ch),
|
||||
}
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, u.String(), nil)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err = fmt.Errorf("subscribe: %w, status: %s, body: %s", err, resp.Status, body)
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sub := &Subscription{
|
||||
conn: conn,
|
||||
reqChannel: make(chan *Request, 10),
|
||||
}
|
||||
go sub.readToChan()
|
||||
return sub
|
||||
}
|
||||
|
||||
func (s *Subscription) readToChan() {
|
||||
for {
|
||||
if s.closed.Load() {
|
||||
break
|
||||
}
|
||||
req := new(Request)
|
||||
if err := s.conn.ReadJSON(req); err != nil {
|
||||
opErr := new(net.OpError)
|
||||
if errors.As(err, &opErr) {
|
||||
break
|
||||
}
|
||||
logging.WithError(err).Error("subscription read")
|
||||
break
|
||||
}
|
||||
s.reqChannel <- req
|
||||
}
|
||||
close(s.reqChannel)
|
||||
}
|
||||
|
||||
// Recv returns the channel over which [Request]s are send.
|
||||
func (s *Subscription) Recv() <-chan *Request {
|
||||
return s.reqChannel
|
||||
}
|
||||
|
||||
func (s *Subscription) Close() error {
|
||||
s.closed.Store(true)
|
||||
return s.conn.Close()
|
||||
}
|
80
apps/api/internal/integration/system.go
Normal file
80
apps/api/internal/integration/system.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/system"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed config/system-user-key.pem
|
||||
systemUserKey []byte
|
||||
//go:embed config/system-user-with-no-permissions.pem
|
||||
systemUserWithNoPermissions []byte
|
||||
)
|
||||
|
||||
var (
|
||||
// SystemClient creates a system connection once and reuses it on every use.
|
||||
// Each client call automatically gets the authorization context for the system user.
|
||||
SystemClient = sync.OnceValue[system.SystemServiceClient](systemClient)
|
||||
SystemToken string
|
||||
SystemUserWithNoPermissionsToken string
|
||||
)
|
||||
|
||||
func systemClient() system.SystemServiceClient {
|
||||
cc, err := grpc.NewClient(loadedConfig.Host(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
ctx = WithSystemAuthorization(ctx)
|
||||
return invoker(ctx, method, req, reply, cc, opts...)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return system.NewSystemServiceClient(cc)
|
||||
}
|
||||
|
||||
func createSystemUserToken() string {
|
||||
const ISSUER = "tester"
|
||||
audience := http_util.BuildOrigin(loadedConfig.Host(), loadedConfig.Secure)
|
||||
signer, err := client.NewSignerFromPrivateKeyByte(systemUserKey, "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
token, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func createSystemUserWithNoPermissionsToken() string {
|
||||
const ISSUER = "system-user-with-no-permissions"
|
||||
audience := http_util.BuildOrigin(loadedConfig.Host(), loadedConfig.Secure)
|
||||
signer, err := client.NewSignerFromPrivateKeyByte(systemUserWithNoPermissions, "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
token, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func WithSystemAuthorization(ctx context.Context) context.Context {
|
||||
return WithAuthorizationToken(ctx, SystemToken)
|
||||
}
|
||||
|
||||
func WithSystemUserWithNoPermissionsAuthorization(ctx context.Context) context.Context {
|
||||
return WithAuthorizationToken(ctx, SystemUserWithNoPermissionsToken)
|
||||
}
|
57
apps/api/internal/integration/user.go
Normal file
57
apps/api/internal/integration/user.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
)
|
||||
|
||||
func (i *Instance) CreateMachineUserPATWithMembership(ctx context.Context, roles ...string) (id, pat string, err error) {
|
||||
user := i.CreateMachineUser(ctx)
|
||||
|
||||
patResp, err := i.Client.Mgmt.AddPersonalAccessToken(ctx, &management.AddPersonalAccessTokenRequest{
|
||||
UserId: user.GetUserId(),
|
||||
ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
orgRoles := make([]string, 0, len(roles))
|
||||
iamRoles := make([]string, 0, len(roles))
|
||||
|
||||
for _, role := range roles {
|
||||
if strings.HasPrefix(role, "ORG_") {
|
||||
orgRoles = append(orgRoles, role)
|
||||
}
|
||||
if strings.HasPrefix(role, "IAM_") {
|
||||
iamRoles = append(iamRoles, role)
|
||||
}
|
||||
}
|
||||
|
||||
if len(orgRoles) > 0 {
|
||||
_, err := i.Client.Mgmt.AddOrgMember(ctx, &management.AddOrgMemberRequest{
|
||||
UserId: user.GetUserId(),
|
||||
Roles: orgRoles,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if len(iamRoles) > 0 {
|
||||
_, err := i.Client.Admin.AddIAMMember(ctx, &admin.AddIAMMemberRequest{
|
||||
UserId: user.GetUserId(),
|
||||
Roles: iamRoles,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
return user.GetUserId(), patResp.GetToken(), nil
|
||||
}
|
90
apps/api/internal/integration/usertype_enumer.go
Normal file
90
apps/api/internal/integration/usertype_enumer.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Code generated by "enumer -type UserType -transform snake -trimprefix UserType"; DO NOT EDIT.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const _UserTypeName = "unspecifiediam_ownerorg_ownerloginno_permission"
|
||||
|
||||
var _UserTypeIndex = [...]uint8{0, 11, 20, 29, 34, 47}
|
||||
|
||||
const _UserTypeLowerName = "unspecifiediam_ownerorg_ownerloginno_permission"
|
||||
|
||||
func (i UserType) String() string {
|
||||
if i < 0 || i >= UserType(len(_UserTypeIndex)-1) {
|
||||
return fmt.Sprintf("UserType(%d)", i)
|
||||
}
|
||||
return _UserTypeName[_UserTypeIndex[i]:_UserTypeIndex[i+1]]
|
||||
}
|
||||
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
func _UserTypeNoOp() {
|
||||
var x [1]struct{}
|
||||
_ = x[UserTypeUnspecified-(0)]
|
||||
_ = x[UserTypeIAMOwner-(1)]
|
||||
_ = x[UserTypeOrgOwner-(2)]
|
||||
_ = x[UserTypeLogin-(3)]
|
||||
_ = x[UserTypeNoPermission-(4)]
|
||||
}
|
||||
|
||||
var _UserTypeValues = []UserType{UserTypeUnspecified, UserTypeIAMOwner, UserTypeOrgOwner, UserTypeLogin, UserTypeNoPermission}
|
||||
|
||||
var _UserTypeNameToValueMap = map[string]UserType{
|
||||
_UserTypeName[0:11]: UserTypeUnspecified,
|
||||
_UserTypeLowerName[0:11]: UserTypeUnspecified,
|
||||
_UserTypeName[11:20]: UserTypeIAMOwner,
|
||||
_UserTypeLowerName[11:20]: UserTypeIAMOwner,
|
||||
_UserTypeName[20:29]: UserTypeOrgOwner,
|
||||
_UserTypeLowerName[20:29]: UserTypeOrgOwner,
|
||||
_UserTypeName[29:34]: UserTypeLogin,
|
||||
_UserTypeLowerName[29:34]: UserTypeLogin,
|
||||
_UserTypeName[34:47]: UserTypeNoPermission,
|
||||
_UserTypeLowerName[34:47]: UserTypeNoPermission,
|
||||
}
|
||||
|
||||
var _UserTypeNames = []string{
|
||||
_UserTypeName[0:11],
|
||||
_UserTypeName[11:20],
|
||||
_UserTypeName[20:29],
|
||||
_UserTypeName[29:34],
|
||||
_UserTypeName[34:47],
|
||||
}
|
||||
|
||||
// UserTypeString retrieves an enum value from the enum constants string name.
|
||||
// Throws an error if the param is not part of the enum.
|
||||
func UserTypeString(s string) (UserType, error) {
|
||||
if val, ok := _UserTypeNameToValueMap[s]; ok {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
if val, ok := _UserTypeNameToValueMap[strings.ToLower(s)]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%s does not belong to UserType values", s)
|
||||
}
|
||||
|
||||
// UserTypeValues returns all values of the enum
|
||||
func UserTypeValues() []UserType {
|
||||
return _UserTypeValues
|
||||
}
|
||||
|
||||
// UserTypeStrings returns a slice of all String values of the enum
|
||||
func UserTypeStrings() []string {
|
||||
strs := make([]string, len(_UserTypeNames))
|
||||
copy(strs, _UserTypeNames)
|
||||
return strs
|
||||
}
|
||||
|
||||
// IsAUserType returns "true" if the value is listed in the enum definition. "false" otherwise
|
||||
func (i UserType) IsAUserType() bool {
|
||||
for _, v := range _UserTypeValues {
|
||||
if i == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
Reference in New Issue
Block a user