chore: merge main to next (#5871)

### Definition of Ready

- [ ] I am happy with the code
- [ ] Short description of the feature/issue is added in the pr
description
- [ ] PR is linked to the corresponding user story
- [ ] Acceptance criteria are met
- [ ] All open todos and follow ups are defined in a new ticket and
justified
- [ ] Deviations from the acceptance criteria and design are agreed with
the PO and documented.
- [ ] No debug or dead code
- [ ] Critical parts are tested automatically
- [ ] Where possible E2E tests are implemented
- [ ] Documentation/examples are up-to-date
- [ ] All non-functional requirements are met
- [ ] Functionality of the acceptance criteria is checked manually on
the dev system.
This commit is contained in:
Livio Spring 2023-05-15 16:51:41 +02:00 committed by GitHub
commit 4982af898a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
147 changed files with 7780 additions and 320 deletions

View File

@ -1,5 +1,3 @@
```[tasklist]
### Definition of Ready ### Definition of Ready
- [ ] I am happy with the code - [ ] I am happy with the code
@ -14,4 +12,3 @@
- [ ] Documentation/examples are up-to-date - [ ] Documentation/examples are up-to-date
- [ ] All non-functional requirements are met - [ ] All non-functional requirements are met
- [ ] Functionality of the acceptance criteria is checked manually on the dev system. - [ ] Functionality of the acceptance criteria is checked manually on the dev system.
```

51
.github/workflows/integration.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Integration tests
on:
push:
tags-ignore:
- '**'
pull_request:
branches:
- '**'
jobs:
run:
strategy:
matrix:
db: [cockroach, postgres]
runs-on: ubuntu-20.04
env:
DOCKER_BUILDKIT: 1
INTEGRATION_DB_FLAVOR: ${{ matrix.db }}
ZITADEL_MASTERKEY: MasterkeyNeedsToHave32Characters
steps:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Source checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver: docker
install: true
- name: Generate gRPC definitions
run: docker build -f build/grpc/Dockerfile -t zitadel-base:local .
- name: Copy gRPC definitions
run: docker build -f build/zitadel/Dockerfile . -t zitadel-go-base --target go-copy -o .
- name: Download Go modules
run: go mod download
- name: Start ${{ matrix.db }} database
run: docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR}
- name: Run zitadel init and setup
run: |
go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
- name: Run integration tests
run: go test -tags=integration -race -parallel 1 -v -coverprofile=profile.cov -coverpkg=./... ./internal/integration ./internal/api/grpc/...
- name: Publish go coverage
uses: codecov/codecov-action@v3.1.0
with:
file: profile.cov
name: integration-tests

View File

@ -44,7 +44,7 @@ jobs:
uses: codecov/codecov-action@v3.1.0 uses: codecov/codecov-action@v3.1.0
with: with:
file: .artifacts/codecov/profile.cov file: .artifacts/codecov/profile.cov
name: go-codecov name: unit-tests
# As goreleaser doesn't build a dockerfile in snapshot mode, we have to build it here # As goreleaser doesn't build a dockerfile in snapshot mode, we have to build it here
- name: Build Docker Image - name: Build Docker Image
run: docker build -t zitadel:pr --file build/Dockerfile .artifacts/zitadel run: docker build -t zitadel:pr --file build/Dockerfile .artifacts/zitadel

View File

@ -199,6 +199,21 @@ When you are happy with your changes, you can cleanup your environment.
docker compose --file ./e2e/config/host.docker.internal/docker-compose.yaml down docker compose --file ./e2e/config/host.docker.internal/docker-compose.yaml down
``` ```
#### Integration tests
In order to run the integrations tests for the gRPC API, PostgreSQL and CockroachDB must be started and initialized:
```bash
export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters"
docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR}
go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go test -tags=integration -race -parallel 1 ./internal/integration ./internal/api/grpc/...
docker compose -f internal/integration/config/docker-compose.yaml down
```
The above can be repeated with `INTEGRATION_DB_FLAVOR="postgres"`.
### Console ### Console
By executing the commands from this section, you run everything you need to develop the console locally. By executing the commands from this section, you run everything you need to develop the console locally.
@ -315,13 +330,15 @@ docker compose down
Project documentation is made with docusaurus and is located under [./docs](./docs). Project documentation is made with docusaurus and is located under [./docs](./docs).
### Local Testing ### Local Testing
Please refer to the [README](./docs/README.md) for more information and local testing. Please refer to the [README](./docs/README.md) for more information and local testing.
### Style Guide ### Style Guide
- **Code with variables**: Make sure that code snippets can be used by setting environment variables, instead of manually replacing a placeholder. - **Code with variables**: Make sure that code snippets can be used by setting environment variables, instead of manually replacing a placeholder.
- **Embedded files**: When embedding mdx files, make sure the template ist prefixed by "_" (lowdash). The content will be rendered inside the parent page, but is not accessible individually (eg, by search). - **Embedded files**: When embedding mdx files, make sure the template ist prefixed by "_" (lowdash). The content will be rendered inside the parent page, but is not accessible individually (eg, by search).
- **Embedded code**: You can embed code snippets from a repository. See the [plugin](https://github.com/saucelabs/docusaurus-theme-github-codeblock#usage) for usage.
### Docs Pull Request ### Docs Pull Request
When making a pull request use `docs(<scope>): <short summary>` as title for the semantic release. When making a pull request use `docs(<scope>): <short summary>` as title for the semantic release.

View File

@ -18,8 +18,9 @@ protoc \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
$(find ${PROTO_PATH} -iname *.proto) $(find ${PROTO_PATH} -iname *.proto)
# install authoption proto compiler # install authoption and zitadel proto compiler
go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-auth go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-auth
go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-zitadel
# output folder for openapi v2 # output folder for openapi v2
mkdir -p ${OPENAPI_PATH} mkdir -p ${OPENAPI_PATH}
@ -79,7 +80,7 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--auth_out=${GOPATH}/src \ --zitadel_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/user/v2alpha/user_service.proto ${PROTO_PATH}/user/v2alpha/user_service.proto
@ -91,8 +92,20 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--auth_out=${GOPATH}/src \ --zitadel_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/session/v2alpha/session_service.proto ${PROTO_PATH}/session/v2alpha/session_service.proto
protoc \
-I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \
--grpc-gateway_opt logtostderr=true \
--grpc-gateway_opt allow_delete_body=true \
--openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \
--zitadel_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/settings/v2alpha/settings_service.proto
echo "done generating grpc" echo "done generating grpc"

View File

@ -26,8 +26,8 @@ func New() *cobra.Command {
adminCMD.AddCommand( adminCMD.AddCommand(
initialise.New(), initialise.New(),
setup.New(), setup.New(),
start.New(), start.New(nil),
start.NewStartFromInit(), start.NewStartFromInit(nil),
key.New(), key.New(),
) )

View File

@ -267,6 +267,7 @@ Console:
LongCache: LongCache:
MaxAge: 12h MaxAge: 12h
SharedMaxAge: 168h #7d SharedMaxAge: 168h #7d
InstanceManagementURL: ""
Notification: Notification:
Repository: Repository:

View File

@ -77,6 +77,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -52,6 +52,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -5,7 +5,7 @@ import (
"crypto/tls" "crypto/tls"
_ "embed" _ "embed"
"fmt" "fmt"
"net" "math"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -33,11 +33,13 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/auth" "github.com/zitadel/zitadel/internal/api/grpc/auth"
"github.com/zitadel/zitadel/internal/api/grpc/management" "github.com/zitadel/zitadel/internal/api/grpc/management"
"github.com/zitadel/zitadel/internal/api/grpc/session/v2" "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
"github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
"github.com/zitadel/zitadel/internal/api/grpc/system" "github.com/zitadel/zitadel/internal/api/grpc/system"
"github.com/zitadel/zitadel/internal/api/grpc/user/v2" "github.com/zitadel/zitadel/internal/api/grpc/user/v2"
http_util "github.com/zitadel/zitadel/internal/api/http" http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/api/robots_txt"
"github.com/zitadel/zitadel/internal/api/saml" "github.com/zitadel/zitadel/internal/api/saml"
"github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/api/ui/login"
@ -45,8 +47,10 @@ import (
"github.com/zitadel/zitadel/internal/authz" "github.com/zitadel/zitadel/internal/authz"
authz_repo "github.com/zitadel/zitadel/internal/authz/repository" authz_repo "github.com/zitadel/zitadel/internal/authz/repository"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore"
@ -60,7 +64,7 @@ import (
"github.com/zitadel/zitadel/openapi" "github.com/zitadel/zitadel/openapi"
) )
func New() *cobra.Command { func New(server chan<- *Server) *cobra.Command {
start := &cobra.Command{ start := &cobra.Command{
Use: "start", Use: "start",
Short: "starts ZITADEL instance", Short: "starts ZITADEL instance",
@ -78,7 +82,7 @@ Requirements:
return err return err
} }
return startZitadel(config, masterKey) return startZitadel(config, masterKey, server)
}, },
} }
@ -87,7 +91,23 @@ Requirements:
return start return start
} }
func startZitadel(config *Config, masterKey string) error { type Server struct {
Config *Config
DB *database.DB
KeyStorage crypto.KeyStorage
Keys *encryptionKeys
Eventstore *eventstore.Eventstore
Queries *query.Queries
AuthzRepo authz_repo.Repository
Storage static.Storage
Commands *command.Commands
LogStore *logstore.Service
Router *mux.Router
TLSConfig *tls.Config
Shutdown chan<- os.Signal
}
func startZitadel(config *Config, masterKey string, server chan<- *Server) error {
ctx := context.Background() ctx := context.Background()
dbClient, err := database.Connect(config.Database, false) dbClient, err := database.Connect(config.Database, false)
@ -110,7 +130,21 @@ func startZitadel(config *Config, masterKey string) error {
return fmt.Errorf("cannot start eventstore for queries: %w", err) return fmt.Errorf("cannot start eventstore for queries: %w", err)
} }
queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, keys.SAML, config.InternalAuthZ.RolePermissionMappings) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
queries, err := query.StartQueries(
ctx,
eventstoreClient,
dbClient,
config.Projections,
config.SystemDefaults,
keys.IDPConfig,
keys.OTP,
keys.OIDC,
keys.SAML,
config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier,
)
if err != nil { if err != nil {
return fmt.Errorf("cannot start queries: %w", err) return fmt.Errorf("cannot start queries: %w", err)
} }
@ -119,6 +153,9 @@ func startZitadel(config *Config, masterKey string) error {
if err != nil { if err != nil {
return fmt.Errorf("error starting authz repo: %w", err) return fmt.Errorf("error starting authz repo: %w", err)
} }
permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
}
storage, err := config.AssetStorage.NewStorage(dbClient.DB) storage, err := config.AssetStorage.NewStorage(dbClient.DB)
if err != nil { if err != nil {
@ -146,7 +183,8 @@ func startZitadel(config *Config, masterKey string) error {
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
&http.Client{}, &http.Client{},
authZRepo, permissionCheck,
sessionTokenVerifier,
) )
if err != nil { if err != nil {
return fmt.Errorf("cannot start commands: %w", err) return fmt.Errorf("cannot start commands: %w", err)
@ -176,11 +214,49 @@ func startZitadel(config *Config, masterKey string) error {
if err != nil { if err != nil {
return err return err
} }
err = startAPIs(ctx, clock, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys, queries, usageReporter) err = startAPIs(
ctx,
clock,
router,
commands,
queries,
eventstoreClient,
dbClient,
config,
storage,
authZRepo,
keys,
queries,
usageReporter,
permissionCheck,
)
if err != nil { if err != nil {
return err return err
} }
return listen(ctx, router, config.Port, tlsConfig)
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
if server != nil {
server <- &Server{
Config: config,
DB: dbClient,
KeyStorage: keyStorage,
Keys: keys,
Eventstore: eventstoreClient,
Queries: queries,
AuthzRepo: authZRepo,
Storage: storage,
Commands: commands,
LogStore: actionsLogstoreSvc,
Router: router,
TLSConfig: tlsConfig,
Shutdown: shutdown,
}
close(server)
}
return listen(ctx, router, config.Port, tlsConfig, shutdown)
} }
func startAPIs( func startAPIs(
@ -197,6 +273,7 @@ func startAPIs(
keys *encryptionKeys, keys *encryptionKeys,
quotaQuerier logstore.QuotaQuerier, quotaQuerier logstore.QuotaQuerier,
usageReporter logstore.UsageReporter, usageReporter logstore.UsageReporter,
permissionCheck domain.PermissionCheck,
) error { ) error {
repo := struct { repo := struct {
authz_repo.Repository authz_repo.Repository
@ -224,8 +301,14 @@ func startAPIs(
if accessSvc.Enabled() { if accessSvc.Enabled() {
logging.Warn("access logs are currently in beta") logging.Warn("access logs are currently in beta")
} }
accessInterceptor := middleware.NewAccessInterceptor(accessSvc, config.Quotas.Access) exhaustedCookieHandler := http_util.NewCookieHandler(
apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc) http_util.WithUnsecure(),
http_util.WithNonHttpOnly(),
http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))),
)
limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access, false)
nonLimitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, nil, config.Quotas.Access, true)
apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc, exhaustedCookieHandler, config.Quotas.Access)
if err != nil { if err != nil {
return fmt.Errorf("error creating api %w", err) return fmt.Errorf("error creating api %w", err)
} }
@ -252,18 +335,28 @@ func startAPIs(
if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil { if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil {
return err return err
} }
if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil {
return err
}
if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
return err return err
} }
instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...)
assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge)
apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle)) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle))
userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources) userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources)
if err != nil { if err != nil {
return err return err
} }
// robots.txt handler
robotsTxtHandler, err := robots_txt.Start()
if err != nil {
return fmt.Errorf("unable to start robots txt handler: %w", err)
}
apis.RegisterHandlerOnPrefix(robots_txt.HandlerPrefix, robotsTxtHandler)
// TODO: Record openapi access logs? // TODO: Record openapi access logs?
openAPIHandler, err := openapi.Start() openAPIHandler, err := openapi.Start()
if err != nil { if err != nil {
@ -271,25 +364,25 @@ func startAPIs(
} }
apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler) apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler)
oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, accessInterceptor.Handle) oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor.Handle)
if err != nil { if err != nil {
return fmt.Errorf("unable to start oidc provider: %w", err) return fmt.Errorf("unable to start oidc provider: %w", err)
} }
apis.RegisterHandlerPrefixes(oidcProvider.HttpHandler(), "/.well-known/openid-configuration", "/oidc/v1", "/oauth/v2") apis.RegisterHandlerPrefixes(oidcProvider.HttpHandler(), "/.well-known/openid-configuration", "/oidc/v1", "/oauth/v2")
samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, accessInterceptor.Handle) samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, limitingAccessInterceptor.Handle)
if err != nil { if err != nil {
return fmt.Errorf("unable to start saml provider: %w", err) return fmt.Errorf("unable to start saml provider: %w", err)
} }
apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler()) apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler())
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, accessInterceptor.Handle, config.CustomerPortal) c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, nonLimitingAccessInterceptor.Handle, config.CustomerPortal)
if err != nil { if err != nil {
return fmt.Errorf("unable to start console: %w", err) return fmt.Errorf("unable to start console: %w", err)
} }
apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c) apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c)
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey) l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
if err != nil { if err != nil {
return fmt.Errorf("unable to start login: %w", err) return fmt.Errorf("unable to start login: %w", err)
} }
@ -301,10 +394,12 @@ func startAPIs(
return nil return nil
} }
func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls.Config) error { func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls.Config, shutdown <-chan os.Signal) error {
http2Server := &http2.Server{} http2Server := &http2.Server{}
http1Server := &http.Server{Handler: h2c.NewHandler(router, http2Server), TLSConfig: tlsConfig} http1Server := &http.Server{Handler: h2c.NewHandler(router, http2Server), TLSConfig: tlsConfig}
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
lc := listenConfig()
lis, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", port))
if err != nil { if err != nil {
return fmt.Errorf("tcp listener on %d failed: %w", port, err) return fmt.Errorf("tcp listener on %d failed: %w", port, err)
} }
@ -321,9 +416,6 @@ func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls
} }
}() }()
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
select { select {
case err := <-errCh: case err := <-errCh:
return fmt.Errorf("error starting server: %w", err) return fmt.Errorf("error starting server: %w", err)

View File

@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/cmd/tls" "github.com/zitadel/zitadel/cmd/tls"
) )
func NewStartFromInit() *cobra.Command { func NewStartFromInit(server chan<- *Server) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "start-from-init", Use: "start-from-init",
Short: "cold starts zitadel", Short: "cold starts zitadel",
@ -37,7 +37,7 @@ Requirements:
startConfig := MustNewConfig(viper.GetViper()) startConfig := MustNewConfig(viper.GetViper())
err = startZitadel(startConfig, masterKey) err = startZitadel(startConfig, masterKey, server)
logging.OnError(err).Fatal("unable to start zitadel") logging.OnError(err).Fatal("unable to start zitadel")
}, },
} }

View File

@ -10,7 +10,7 @@ import (
"github.com/zitadel/zitadel/cmd/tls" "github.com/zitadel/zitadel/cmd/tls"
) )
func NewStartFromSetup() *cobra.Command { func NewStartFromSetup(server chan<- *Server) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "start-from-setup", Use: "start-from-setup",
Short: "cold starts zitadel", Short: "cold starts zitadel",
@ -35,7 +35,7 @@ Requirements:
startConfig := MustNewConfig(viper.GetViper()) startConfig := MustNewConfig(viper.GetViper())
err = startZitadel(startConfig, masterKey) err = startZitadel(startConfig, masterKey, server)
logging.OnError(err).Fatal("unable to start zitadel") logging.OnError(err).Fatal("unable to start zitadel")
}, },
} }

11
cmd/start/start_port.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !integration
package start
import (
"net"
)
func listenConfig() *net.ListenConfig {
return &net.ListenConfig{}
}

View File

@ -0,0 +1,25 @@
//go:build integration
package start
import (
"net"
"syscall"
"golang.org/x/sys/unix"
)
func listenConfig() *net.ListenConfig {
return &net.ListenConfig{
Control: reusePort,
}
}
func reusePort(network, address string, conn syscall.RawConn) error {
return conn.Control(func(descriptor uintptr) {
err := syscall.SetsockoptInt(int(descriptor), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
if err != nil {
panic(err)
}
})
}

View File

@ -26,7 +26,7 @@ var (
defaultConfig []byte defaultConfig []byte
) )
func New(out io.Writer, in io.Reader, args []string) *cobra.Command { func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "zitadel", Use: "zitadel",
Short: "The ZITADEL CLI lets you interact with ZITADEL", Short: "The ZITADEL CLI lets you interact with ZITADEL",
@ -51,9 +51,9 @@ func New(out io.Writer, in io.Reader, args []string) *cobra.Command {
admin.New(), //is now deprecated, remove later on admin.New(), //is now deprecated, remove later on
initialise.New(), initialise.New(),
setup.New(), setup.New(),
start.New(), start.New(server),
start.NewStartFromInit(), start.NewStartFromInit(server),
start.NewStartFromSetup(), start.NewStartFromSetup(server),
key.New(), key.New(),
) )

View File

@ -30,19 +30,23 @@
<span class="fill-space"></span> <span class="fill-space"></span>
<div class="app-specs cnsl-secondary-text"> <div class="app-specs cnsl-secondary-text">
<div class="row" *ngIf="isOIDC && method && method.responseType !== undefined"> <div class="row" *ngIf="isOIDC && method && method.responseType !== undefined">
<span>{{ 'APP.OIDC.RESPONSETYPE' | translate }}</span> <span class="row-entry">{{ 'APP.OIDC.RESPONSETYPE' | translate }}</span>
<span>{{ 'APP.OIDC.RESPONSE.' + method.responseType.toString() | translate }}</span> <span>{{ 'APP.OIDC.RESPONSE.' + method.responseType.toString() | translate }}</span>
</div> </div>
<div class="row" *ngIf="isOIDC && method.grantType !== undefined"> <div class="row" *ngIf="isOIDC && method.grantType !== undefined">
<span>{{ 'APP.GRANT' | translate }}</span> <span class="row-entry">{{ 'APP.GRANT' | translate }}</span>
<span>{{ 'APP.OIDC.GRANT.' + method.grantType.toString() | translate }}</span> <span
><span class="space" *ngFor="let grant of method.grantType">{{
'APP.OIDC.GRANT.' + grant.toString() | translate
}}</span></span
>
</div> </div>
<div class="row" *ngIf="isOIDC && method.authMethod !== undefined"> <div class="row" *ngIf="isOIDC && method.authMethod !== undefined">
<span>{{ 'APP.AUTHMETHOD' | translate }}</span> <span class="row-entry">{{ 'APP.AUTHMETHOD' | translate }}</span>
<span>{{ 'APP.OIDC.AUTHMETHOD.' + method.authMethod.toString() | translate }}</span> <span>{{ 'APP.OIDC.AUTHMETHOD.' + method.authMethod.toString() | translate }}</span>
</div> </div>
<div class="row" *ngIf="!isOIDC && method.apiAuthMethod !== undefined"> <div class="row" *ngIf="!isOIDC && method.apiAuthMethod !== undefined">
<span>{{ 'APP.AUTHMETHOD' | translate }}</span> <span class="row-entry">{{ 'APP.AUTHMETHOD' | translate }}</span>
<span>{{ 'APP.API.AUTHMETHOD.' + method.apiAuthMethod.toString() | translate }}</span> <span>{{ 'APP.API.AUTHMETHOD.' + method.apiAuthMethod.toString() | translate }}</span>
</div> </div>
</div> </div>

View File

@ -155,7 +155,11 @@
white-space: nowrap; white-space: nowrap;
} }
:first-child { .space {
margin-left: 0.5rem;
}
.row-entry {
margin-right: 1rem; margin-right: 1rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@ -14,7 +14,7 @@ export interface RadioItemAuthType {
prefix: string; prefix: string;
background: string; background: string;
responseType?: OIDCResponseType; responseType?: OIDCResponseType;
grantType?: OIDCGrantType; grantType?: OIDCGrantType[];
authMethod?: OIDCAuthMethodType; authMethod?: OIDCAuthMethodType;
apiAuthMethod?: APIAuthMethodType; apiAuthMethod?: APIAuthMethodType;
recommended?: boolean; recommended?: boolean;

View File

@ -58,13 +58,9 @@
</form> </form>
</mat-step> </mat-step>
<!-- skip for native OIDC and SAML applications --> <!-- skip for SAML applications -->
<mat-step <mat-step
*ngIf=" *ngIf="appType?.value?.createType === AppCreateType.OIDC || appType?.value?.createType === AppCreateType.API"
(appType?.value?.createType === AppCreateType.OIDC &&
appType?.value.oidcAppType !== OIDCAppType.OIDC_APP_TYPE_NATIVE) ||
appType?.value?.createType === AppCreateType.API
"
[stepControl]="secondFormGroup" [stepControl]="secondFormGroup"
[editable]="true" [editable]="true"
> >
@ -93,9 +89,11 @@
</div> </div>
</form> </form>
</mat-step> </mat-step>
<!-- show redirect step only for OIDC apps --> <!-- show redirect step only for OIDC apps -->
<mat-step *ngIf="appType?.value?.createType === AppCreateType.OIDC" [editable]="true"> <mat-step
*ngIf="appType?.value?.createType === AppCreateType.OIDC && authMethod?.value !== 'DEVICECODE'"
[editable]="true"
>
<ng-template matStepLabel>{{ 'APP.OIDC.REDIRECTSECTION' | translate }}</ng-template> <ng-template matStepLabel>{{ 'APP.OIDC.REDIRECTSECTION' | translate }}</ng-template>
<p class="step-title">{{ 'APP.OIDC.REDIRECTTITLE' | translate }}</p> <p class="step-title">{{ 'APP.OIDC.REDIRECTTITLE' | translate }}</p>
@ -431,7 +429,13 @@
</ng-container> </ng-container>
</div> </div>
<div class="content" *ngIf="formappType?.value?.createType === AppCreateType.OIDC"> <div
class="content"
*ngIf="
formappType?.value?.createType === AppCreateType.OIDC &&
!(oidcAppRequest.toObject().appType === OIDCAppType.OIDC_APP_TYPE_NATIVE && grantTypesListContainsOnlyDeviceCode)
"
>
<div class="formfield full-width"> <div class="formfield full-width">
<cnsl-redirect-uris <cnsl-redirect-uris
class="redirect-section" class="redirect-section"

View File

@ -32,6 +32,7 @@ import { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog
import { import {
BASIC_AUTH_METHOD, BASIC_AUTH_METHOD,
CODE_METHOD, CODE_METHOD,
DEVICE_CODE_METHOD,
getPartialConfigFromAuthMethod, getPartialConfigFromAuthMethod,
IMPLICIT_METHOD, IMPLICIT_METHOD,
PKCE_METHOD, PKCE_METHOD,
@ -112,6 +113,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
{ type: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, checked: true, disabled: false }, { type: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, checked: true, disabled: false },
{ type: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, checked: false, disabled: true }, { type: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, checked: false, disabled: true },
{ type: OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, checked: false, disabled: true }, { type: OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, checked: false, disabled: true },
{ type: OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, checked: false, disabled: true },
]; ];
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
@ -163,7 +165,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
switch (this.appType?.value.oidcAppType) { switch (this.appType?.value.oidcAppType) {
case OIDCAppType.OIDC_APP_TYPE_NATIVE: case OIDCAppType.OIDC_APP_TYPE_NATIVE:
this.authMethods = [PKCE_METHOD]; this.authMethods = [PKCE_METHOD, DEVICE_CODE_METHOD];
// automatically set to PKCE and skip step // automatically set to PKCE and skip step
this.oidcAppRequest.setResponseTypesList([OIDCResponseType.OIDC_RESPONSE_TYPE_CODE]); this.oidcAppRequest.setResponseTypesList([OIDCResponseType.OIDC_RESPONSE_TYPE_CODE]);
@ -473,6 +475,13 @@ export class AppCreateComponent implements OnInit, OnDestroy {
return this.form.get('grantTypesList'); return this.form.get('grantTypesList');
} }
get grantTypesListContainsOnlyDeviceCode(): boolean {
return (
this.oidcAppRequest.toObject().grantTypesList.length === 1 &&
this.oidcAppRequest.toObject().grantTypesList[0] === OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE
);
}
get formappType(): AbstractControl | null { get formappType(): AbstractControl | null {
return this.form.get('appType'); return this.form.get('appType');
} }
@ -480,9 +489,6 @@ export class AppCreateComponent implements OnInit, OnDestroy {
get formMetadataUrl(): AbstractControl | null { get formMetadataUrl(): AbstractControl | null {
return this.form.get('metadataUrl'); return this.form.get('metadataUrl');
} }
// get formapplicationType(): AbstractControl | null {
// return this.form.get('applicationType');
// }
get authMethodType(): AbstractControl | null { get authMethodType(): AbstractControl | null {
return this.form.get('authMethodType'); return this.form.get('authMethodType');

View File

@ -46,6 +46,7 @@ import {
BASIC_AUTH_METHOD, BASIC_AUTH_METHOD,
CODE_METHOD, CODE_METHOD,
CUSTOM_METHOD, CUSTOM_METHOD,
DEVICE_CODE_METHOD,
getAuthMethodFromPartialConfig, getAuthMethodFromPartialConfig,
getPartialConfigFromAuthMethod, getPartialConfigFromAuthMethod,
IMPLICIT_METHOD, IMPLICIT_METHOD,
@ -89,6 +90,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public oidcGrantTypes: OIDCGrantType[] = [ public oidcGrantTypes: OIDCGrantType[] = [
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT,
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
]; ];
public oidcAppTypes: OIDCAppType[] = [ public oidcAppTypes: OIDCAppType[] = [
@ -274,6 +276,16 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (this.app.oidcConfig) { if (this.app.oidcConfig) {
this.getAuthMethodOptions('OIDC'); this.getAuthMethodOptions('OIDC');
if (
this.app.oidcConfig.grantTypesList.length === 1 &&
this.app.oidcConfig.grantTypesList[0] === OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE
) {
this.settingsList = [
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
{ id: 'token', i18nKey: 'APP.TOKEN' },
{ id: 'urls', i18nKey: 'APP.URLS' },
];
} else {
this.settingsList = [ this.settingsList = [
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' }, { id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
{ id: 'token', i18nKey: 'APP.TOKEN' }, { id: 'token', i18nKey: 'APP.TOKEN' },
@ -281,6 +293,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
{ id: 'additional-origins', i18nKey: 'APP.ADDITIONALORIGINS' }, { id: 'additional-origins', i18nKey: 'APP.ADDITIONALORIGINS' },
{ id: 'urls', i18nKey: 'APP.URLS' }, { id: 'urls', i18nKey: 'APP.URLS' },
]; ];
}
this.initialAuthMethod = this.authMethodFromPartialConfig({ oidc: this.app.oidcConfig }); this.initialAuthMethod = this.authMethodFromPartialConfig({ oidc: this.app.oidcConfig });
this.currentAuthMethod = this.initialAuthMethod; this.currentAuthMethod = this.initialAuthMethod;
@ -381,7 +394,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (type === 'OIDC') { if (type === 'OIDC') {
switch (this.app?.oidcConfig?.appType) { switch (this.app?.oidcConfig?.appType) {
case OIDCAppType.OIDC_APP_TYPE_NATIVE: case OIDCAppType.OIDC_APP_TYPE_NATIVE:
this.authMethods = [PKCE_METHOD, CUSTOM_METHOD]; this.authMethods = [PKCE_METHOD, DEVICE_CODE_METHOD, CUSTOM_METHOD];
break; break;
case OIDCAppType.OIDC_APP_TYPE_WEB: case OIDCAppType.OIDC_APP_TYPE_WEB:
this.authMethods = [PKCE_METHOD, CODE_METHOD, PK_JWT_METHOD, POST_METHOD]; this.authMethods = [PKCE_METHOD, CODE_METHOD, PK_JWT_METHOD, POST_METHOD];

View File

@ -16,10 +16,11 @@ export const CODE_METHOD: RadioItemAuthType = {
prefix: 'CODE', prefix: 'CODE',
background: 'linear-gradient(40deg, rgb(25 105 143) 30%, rgb(23 95 129))', background: 'linear-gradient(40deg, rgb(25 105 143) 30%, rgb(23 95 129))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC,
recommended: false, recommended: false,
}; };
export const PKCE_METHOD: RadioItemAuthType = { export const PKCE_METHOD: RadioItemAuthType = {
key: 'PKCE', key: 'PKCE',
titleI18nKey: 'APP.AUTHMETHODS.PKCE.TITLE', titleI18nKey: 'APP.AUTHMETHODS.PKCE.TITLE',
@ -28,10 +29,11 @@ export const PKCE_METHOD: RadioItemAuthType = {
prefix: 'PKCE', prefix: 'PKCE',
background: 'linear-gradient(40deg, #059669 30%, #047857)', background: 'linear-gradient(40deg, #059669 30%, #047857)',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
recommended: true, recommended: true,
}; };
export const POST_METHOD: RadioItemAuthType = { export const POST_METHOD: RadioItemAuthType = {
key: 'POST', key: 'POST',
titleI18nKey: 'APP.AUTHMETHODS.POST.TITLE', titleI18nKey: 'APP.AUTHMETHODS.POST.TITLE',
@ -40,10 +42,11 @@ export const POST_METHOD: RadioItemAuthType = {
prefix: 'POST', prefix: 'POST',
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))', background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
notRecommended: true, notRecommended: true,
}; };
export const PK_JWT_METHOD: RadioItemAuthType = { export const PK_JWT_METHOD: RadioItemAuthType = {
key: 'PK_JWT', key: 'PK_JWT',
titleI18nKey: 'APP.AUTHMETHODS.PK_JWT.TITLE', titleI18nKey: 'APP.AUTHMETHODS.PK_JWT.TITLE',
@ -52,11 +55,12 @@ export const PK_JWT_METHOD: RadioItemAuthType = {
prefix: 'JWT', prefix: 'JWT',
background: 'linear-gradient(40deg, rgb(70 77 145) 30%, rgb(58 65 124))', background: 'linear-gradient(40deg, rgb(70 77 145) 30%, rgb(58 65 124))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT,
apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT,
// recommended: true, // recommended: true,
}; };
export const BASIC_AUTH_METHOD: RadioItemAuthType = { export const BASIC_AUTH_METHOD: RadioItemAuthType = {
key: 'BASIC', key: 'BASIC',
titleI18nKey: 'APP.AUTHMETHODS.BASIC.TITLE', titleI18nKey: 'APP.AUTHMETHODS.BASIC.TITLE',
@ -65,7 +69,7 @@ export const BASIC_AUTH_METHOD: RadioItemAuthType = {
prefix: 'BASIC', prefix: 'BASIC',
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))', background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_BASIC, apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_BASIC,
}; };
@ -78,11 +82,24 @@ export const IMPLICIT_METHOD: RadioItemAuthType = {
prefix: 'IMP', prefix: 'IMP',
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))', background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_ID_TOKEN, responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_ID_TOKEN,
grantType: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, grantType: [OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE, authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
notRecommended: true, notRecommended: true,
}; };
export const DEVICE_CODE_METHOD: RadioItemAuthType = {
key: 'DEVICECODE',
titleI18nKey: 'APP.AUTHMETHODS.DEVICECODE.TITLE',
descI18nKey: 'APP.AUTHMETHODS.DEVICECODE.DESCRIPTION',
disabled: false,
prefix: 'DEVICECODE',
background: 'linear-gradient(40deg, rgb(56 189 248) 30%, rgb(14 165 233))',
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC,
recommended: false,
};
export const CUSTOM_METHOD: RadioItemAuthType = { export const CUSTOM_METHOD: RadioItemAuthType = {
key: 'CUSTOM', key: 'CUSTOM',
titleI18nKey: 'APP.AUTHMETHODS.CUSTOM.TITLE', titleI18nKey: 'APP.AUTHMETHODS.CUSTOM.TITLE',
@ -112,6 +129,15 @@ export function getPartialConfigFromAuthMethod(authMethod: string):
}, },
}; };
return config; return config;
case DEVICE_CODE_METHOD.key:
config = {
oidc: {
responseTypesList: [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
grantTypesList: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
authMethodType: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
},
};
return config;
case PKCE_METHOD.key: case PKCE_METHOD.key:
config = { config = {
oidc: { oidc: {
@ -211,6 +237,38 @@ export function getAuthMethodFromPartialConfig(config: {
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST, OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
]); ]);
const deviceCode = JSON.stringify([
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
[OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
]);
const deviceCodeWithCode = JSON.stringify([
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
[
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
// OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
],
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
]);
const deviceCodeWithCodeAndRefresh = JSON.stringify([
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
[
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
],
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
]);
const deviceCodeWithRefresh = JSON.stringify([
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
[OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN],
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
]);
const pkjwt = JSON.stringify([ const pkjwt = JSON.stringify([
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE], [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
[OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE], [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
@ -245,6 +303,15 @@ export function getAuthMethodFromPartialConfig(config: {
case postWithRefresh: case postWithRefresh:
return POST_METHOD.key; return POST_METHOD.key;
case deviceCode:
return DEVICE_CODE_METHOD.key;
case deviceCodeWithCode:
return DEVICE_CODE_METHOD.key;
case deviceCodeWithRefresh:
return DEVICE_CODE_METHOD.key;
case deviceCodeWithCodeAndRefresh:
return DEVICE_CODE_METHOD.key;
case pkjwt: case pkjwt:
return PK_JWT_METHOD.key; return PK_JWT_METHOD.key;
case pkjwtWithRefresh: case pkjwtWithRefresh:

View File

@ -1965,7 +1965,8 @@
"GRANT": { "GRANT": {
"0": "Authorization Code", "0": "Authorization Code",
"1": "Implicit", "1": "Implicit",
"2": "Refresh Token" "2": "Refresh Token",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2056,6 +2057,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "Erhalte die Token direkt vom authorize Endpoint" "DESCRIPTION": "Erhalte die Token direkt vom authorize Endpoint"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Autorisieren Sie das Gerät auf einem Computer oder Smartphone."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Custom", "TITLE": "Custom",
"DESCRIPTION": "Deine Konfiguration entspricht keiner anderen Option." "DESCRIPTION": "Deine Konfiguration entspricht keiner anderen Option."

View File

@ -1962,7 +1962,8 @@
"GRANT": { "GRANT": {
"0": "Authorization Code", "0": "Authorization Code",
"1": "Implicit", "1": "Implicit",
"2": "Refresh Token" "2": "Refresh Token",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2053,6 +2054,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "Get the tokens directly from the authorization endpoint" "DESCRIPTION": "Get the tokens directly from the authorization endpoint"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Authorize the device on a computer or smartphone."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Custom", "TITLE": "Custom",
"DESCRIPTION": "Your setting doesn't correspond to any other option." "DESCRIPTION": "Your setting doesn't correspond to any other option."

View File

@ -1962,7 +1962,8 @@
"GRANT": { "GRANT": {
"0": "Código de autorización", "0": "Código de autorización",
"1": "Implícito", "1": "Implícito",
"2": "Token de refresco" "2": "Token de refresco",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Básico", "0": "Básico",
@ -2053,6 +2054,10 @@
"TITLE": "Implícita", "TITLE": "Implícita",
"DESCRIPTION": "Obtén los tokens directamente del endpoint de autorización" "DESCRIPTION": "Obtén los tokens directamente del endpoint de autorización"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Autorizar el dispositivo en una computadora o teléfono."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Personalizada", "TITLE": "Personalizada",
"DESCRIPTION": "Tu configuración no se corresponde con alguna de las otras opciones." "DESCRIPTION": "Tu configuración no se corresponde con alguna de las otras opciones."

View File

@ -1966,7 +1966,8 @@
"GRANT": { "GRANT": {
"0": "Code d'autorisation", "0": "Code d'autorisation",
"1": "Implicite", "1": "Implicite",
"2": "Rafraîchir le jeton" "2": "Rafraîchir le jeton",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2045,6 +2046,10 @@
"TITLE": "Implicite", "TITLE": "Implicite",
"DESCRIPTION": "Obtenir les jetons directement à partir du point final d'autorisation" "DESCRIPTION": "Obtenir les jetons directement à partir du point final d'autorisation"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Autoriser l'appareil sur un ordinateur ou un smartphone."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Personnalisé", "TITLE": "Personnalisé",
"DESCRIPTION": "Votre paramètre ne correspond à aucune autre option." "DESCRIPTION": "Votre paramètre ne correspond à aucune autre option."

View File

@ -1967,7 +1967,8 @@
"GRANT": { "GRANT": {
"0": "Authorization Code", "0": "Authorization Code",
"1": "Implicit", "1": "Implicit",
"2": "Refresh Token" "2": "Refresh Token",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2058,6 +2059,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "Ottenere i token direttamente dall'endpoint di autorizzazione" "DESCRIPTION": "Ottenere i token direttamente dall'endpoint di autorizzazione"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Autorizza il dispositivo su un computer o uno smartphone."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Custom", "TITLE": "Custom",
"DESCRIPTION": "La tua impostazione non corrisponde a nessun'altra opzione." "DESCRIPTION": "La tua impostazione non corrisponde a nessun'altra opzione."

View File

@ -1957,7 +1957,8 @@
"GRANT": { "GRANT": {
"0": "Authorization Code", "0": "Authorization Code",
"1": "Implicit", "1": "Implicit",
"2": "Refresh Token" "2": "Refresh Token",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2048,6 +2049,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "認証エンドポイントから直接トークンを取得します。" "DESCRIPTION": "認証エンドポイントから直接トークンを取得します。"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "コンピューターまたはスマートフォンでデバイスを認証します。"
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Custom", "TITLE": "Custom",
"DESCRIPTION": "設定は他のオプションに対応していません。" "DESCRIPTION": "設定は他のオプションに対応していません。"

View File

@ -1966,7 +1966,8 @@
"GRANT": { "GRANT": {
"0": "Kod autoryzacyjny", "0": "Kod autoryzacyjny",
"1": "Implicite", "1": "Implicite",
"2": "Token odświeżający" "2": "Token odświeżający",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Podstawowy", "0": "Podstawowy",
@ -2057,6 +2058,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "Pobierz tokeny bezpośrednio z punktu autoryzacyjnego" "DESCRIPTION": "Pobierz tokeny bezpośrednio z punktu autoryzacyjnego"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "Autoryzuj urządzenie na komputerze lub smartfonie."
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Niestandardowy", "TITLE": "Niestandardowy",
"DESCRIPTION": "Twoje ustawienie nie odpowiada żadnej innej opcji." "DESCRIPTION": "Twoje ustawienie nie odpowiada żadnej innej opcji."

View File

@ -1965,7 +1965,8 @@
"GRANT": { "GRANT": {
"0": "Authorization Code", "0": "Authorization Code",
"1": "Implicit", "1": "Implicit",
"2": "Refresh Token" "2": "Refresh Token",
"3": "Device Code"
}, },
"AUTHMETHOD": { "AUTHMETHOD": {
"0": "Basic", "0": "Basic",
@ -2044,6 +2045,10 @@
"TITLE": "Implicit", "TITLE": "Implicit",
"DESCRIPTION": "直接从授权端点获取令牌" "DESCRIPTION": "直接从授权端点获取令牌"
}, },
"DEVICECODE": {
"TITLE": "Device Code",
"DESCRIPTION": "在计算机或智能手机上授权设备。"
},
"CUSTOM": { "CUSTOM": {
"TITLE": "Custom", "TITLE": "Custom",
"DESCRIPTION": "您的设置与任何其他选项都不对应。" "DESCRIPTION": "您的设置与任何其他选项都不对应。"

View File

@ -21,6 +21,7 @@
<meta name="twitter:title" content="ZITADEL Console" /> <meta name="twitter:title" content="ZITADEL Console" />
<meta name="twitter:description" content="Management Platform for ZITADEL IAM" /> <meta name="twitter:description" content="Management Platform for ZITADEL IAM" />
<meta name="twitter:image" content="https://www.zitadel.com/images/preview.png" /> <meta name="twitter:image" content="https://www.zitadel.com/images/preview.png" />
<meta name="robots" content="none" />
</head> </head>
<body> <body>

View File

@ -1,10 +0,0 @@
---
title: ZITADEL Cloud Rate Limits
---
Rate limits are implemented according to our [rate limit policy](/legal/rate-limit-policy.md) with the following rules:
| Path | Description | Rate Limiting | One Minute Banning |
|--------------------------|----------------------------------------|--------------------------------------|----------------------------------------|
| /ui/login* | Global Login, Register and Reset Limit | 10 requests per second over a minute | 15 requests per second over 3 minutes |
| All other paths | All gRPC- and REST APIs as well as the ZITADEL Customer Portal | 10 requests per second over a minute | 10 requests per second over 3 minutes |

View File

@ -0,0 +1,64 @@
---
title: Audit Trail
---
ZITADEL provides you with an built-in audit trail to track all changes and events over an unlimited period of time.
Most other solutions replace a historic record and track changes in a separate log when information is updated.
ZITADEL only ever appends data in an [Eventstore](https://zitadel.com/docs/concepts/eventstore), keeping all historic record.
The audit trail itself is identical to the state, since ZITADEL calculates the state from all the past changes.
![Example of events that happen for a profile change and a login](/img/concepts/audit-trail/audit-log-events.png)
This form of audit log has several benefits over storing classic audit logs.
You can view past data in-context of the whole system at a single point in time.
Reviewing a past state of the application can be important when tracing an incident that happened months back. Moreover the eventstore provides a truly complete and clean audit log.
## Accessing the Audit Log
### Last changes of an object
You can check the last changes of most objects in the [Console](/docs/guides/manage/console/overview).
In the following screenshot you can see an example of last changes on an [user](/docs/guides/manage/console/users).
The same view is available on several other objects such as organization or project.
![Profile Self Manage](/img/guides/console/myprofile.png)
### Event View
Administrators can see all events across an instance and filter them directly in [Console](/docs/guides/manage/console/overview).
Go to your instance settings and then click on the Tab **Events** to open the Event Viewer or browse to $YOUR_DOMAIN/ui/console/events
![Event viewer](/img/concepts/audit-trail/event-viewer.png)
### Event API
Since everything that is available in Console can also be called with our APIs, you can access all events and audit data trough our APIs:
- [Event API Guide](/docs/guides/integrate/event-api)
- [API Documentation](/docs/category/apis/admin/events)
Access to the API is possible with a [Service User](/docs/guides/integrate/serviceusers) account, allowing you to integrate the events with your own business logic.
## Using logs in external systems
You can use the [Event API](#event-api) to pull data and ingest it in an external system.
[Actions](actions.md) can be used to write events to the stdout and [process the events as logs](../../self-hosting/manage/production#logging).
Please refer to the zitadel/actions repository for a [code sample](https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js).
You can use your log processing pipeline to parse and ingest the events in your favorite analytics tool.
It is possible to send events directly with an http request to an external tool.
We don't recommend this approach since this would create back-pressure and increase the overall processing time for requests.
:::info Scope of Actions
At this moment Actions can be invoked on certain events, but not generally on every event.
This is not a technical limitation, but a [feature on our backlog](https://github.com/zitadel/zitadel/issues/5101).
:::
## Future plans
There will be three major areas for future development on the audit data
- [Metrics](https://github.com/zitadel/zitadel/issues/4458) and [standard reports](https://github.com/zitadel/zitadel/discussions/2162#discussioncomment-1153259)
- [Feedback loop](https://github.com/zitadel/zitadel/issues/5102) and threat detection
- Forensics and replay of events

View File

@ -14,6 +14,8 @@ which in turn can represent your own company (e.g. departments), your business c
Read more about how to configure your instance in our [instance guide](/guides/manage/console/instance-settings). Read more about how to configure your instance in our [instance guide](/guides/manage/console/instance-settings).
![Two instances with each organizations in it using the same database](/img/concepts/objects/instances.png)
## Multiple Virtual Instances ## Multiple Virtual Instances
ZITADEL has the concept of virtual instances. ZITADEL has the concept of virtual instances.

View File

@ -17,7 +17,7 @@ All that is required, is a service account with an Org Owner (or another role, d
However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of : However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of :
- [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md) - [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md)
- [Service Users](../../guides/integrate/serviceusers.md) - [Service Users](../../guides/integrate/serviceusers)
> Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide. > Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide.

View File

@ -14,7 +14,7 @@ All that is required, is a service account with an Org Owner (or another role, d
However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of : However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of :
- [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md) - [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md)
- [Service Users](../../guides/integrate/serviceusers.md) - [Service Users](../../guides/integrate/serviceusers)
> Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide. > Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide.

View File

@ -19,7 +19,7 @@ On each level we have some different Roles. Here you can find more about the dif
## Add ORG_OWNER to Service User ## Add ORG_OWNER to Service User
Make sure you have a Service User with a Key. (For more detailed informations about creating a service user go to [Service User](serviceusers.md)) Make sure you have a Service User with a Key. (For more detailed informations about creating a service user go to [Service User](serviceusers))
1. Navigate to Organization Detail 1. Navigate to Organization Detail
2. Click the **+** button in the right part of console, in the managers part of details 2. Click the **+** button in the right part of console, in the managers part of details
@ -31,7 +31,7 @@ Make sure you have a Service User with a Key. (For more detailed informations ab
## Authenticating a service user ## Authenticating a service user
In ZITADEL we use the `urn:ietf:params:oauth:grant-type:jwt-bearer` (**“JWT bearer token with private key”**, [RFC7523](https://tools.ietf.org/html/rfc7523)) authorization grant for this non-interactive authentication. In ZITADEL we use the `urn:ietf:params:oauth:grant-type:jwt-bearer` (**“JWT bearer token with private key”**, [RFC7523](https://tools.ietf.org/html/rfc7523)) authorization grant for this non-interactive authentication.
This is already described in the [Service User](serviceusers.md), so make sure you follow this guide. This is already described in the [Service User](./serviceusers), so make sure you follow this guide.
### Request an OAuth token, with audience for ZITADEL ### Request an OAuth token, with audience for ZITADEL

View File

@ -86,7 +86,7 @@ If your system is exposed without TLS or on a dedicated port, be sure to provide
If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run: If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run:
```bash ```bash
./key2jwt -audience=https://custom-domain.com -key=system-user-1.pem -issuer=system-user-1 zitadel-tools key2jwt --audience=https://custom-domain.com --key=system-user-1.pem --issuer=system-user-1
``` ```
## Call the System API ## Call the System API

View File

@ -1,5 +1,5 @@
--- ---
title: Service Users title: Private Key JWT
--- ---
This is a guide on how to create service users in ZITADEL. You can read more about users [here](/concepts/structure/users.md). This is a guide on how to create service users in ZITADEL. You can read more about users [here](/concepts/structure/users.md).

View File

@ -4,7 +4,7 @@ services:
traefik: traefik:
networks: networks:
- 'zitadel' - 'zitadel'
image: "traefik:v2.7" image: "traefik:v2.10.1"
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"

View File

@ -1,3 +1,8 @@
log:
level: DEBUG
accessLog: {}
entrypoints: entrypoints:
web: web:
address: ":80" address: ":80"

View File

@ -23,3 +23,8 @@ Database:
RootCert: "/crdb-certs/ca.crt" RootCert: "/crdb-certs/ca.crt"
Cert: "/crdb-certs/client.root.crt" Cert: "/crdb-certs/client.root.crt"
Key: "/crdb-certs/client.root.key" Key: "/crdb-certs/client.root.key"
LogStore:
Access:
Stdout:
Enabled: true

View File

@ -70,3 +70,8 @@ This is the IAM admin users login according to your configuration in the [exampl
- **password**: *RootPassword1!* - **password**: *RootPassword1!*
Read more about [the login process](/guides/integrate/login-users). Read more about [the login process](/guides/integrate/login-users).
## Troubleshooting
You can connect to cockroach like this: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --host my-cockroach-db --certs-dir /cockroach/certs/`
For example, to show all login names: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --database zitadel --host my-cockroach-db --certs-dir /cockroach/certs/ --execute "select * from projections.login_names2"`

View File

@ -45,6 +45,22 @@ Tracing:
MetricPrefix: zitadel MetricPrefix: zitadel
``` ```
## Logging
ZITADEL follows the principles that guide cloud-native and twelve factor applications.
Logs are a stream of time-ordered events collected from all running processes.
ZITADEL processes write the following events to the standard output:
- Runtime Logs: Define the log level and record format [in the Log configuration section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L1-L4)
- Access Logs: Enable logging all HTTP and gRPC responses from the ZITADEL binary [in the LogStore section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L366)
- Actions Exectution Logs: Actions can emit custom logs at different levels. For example, a log record can be emitted each time a user is created or authenticated. If you don't want to have these logs in STDOUT, you can disable this [in the LogStore section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L387) .
Log file management should not be in each business apps responsibility.
Instead, your execution environment should provide tooling for managing logs in a generic way.
This includes tasks like rotating files, routing, collecting, archiving and cleaning-up.
For example, systemd has journald and kubernetes has fluentd and fluentbit.
## Database ## Database
### Prefer CockroachDB ### Prefer CockroachDB

View File

@ -0,0 +1,26 @@
---
title: Technical Advisory 10000
---
## Description
Currently, by default, users are directed to the "Select Account Page" on the ZITADEL login.
However, this can be modified by including a [prompt or a login hint](/docs/apis/openidoauth/endpoints#additional-parameters) in the authentication request.
As a result of this default behavior, users who already have an active session in one application and wish to log in to a second one will need to select their user account, even if no other session is active.
To address this, we are going to change this behavior so that users will be automatically authenticated when logging into a second application, as long as they only have one active session.
## Statement
This behaviour change is tracked in the following issue: [Reuse current session if no prompt is selected ](https://github.com/zitadel/zitadel/issues/4841)
As soon as the release version is published, we will include the version here.
## Mitigation
If you want to prompt users to always select their account on purpose, please make sure to include the `select_account` [prompt](/docs/apis/openidoauth/endpoints#additional-parameters) in your authentication request.
## Impact
Once this update has been released and deployed, your users will be automatically authenticated
No action will be required on your part if this is the intended behavior.

View File

@ -0,0 +1,39 @@
---
title: Technical Advisory
---
Technical advisories are notices that report major issues with ZITADEL Self-Hosted or the ZITADEL Cloud platform that could potentially impact security or stability in production environments.
These advisories may include details about the nature of the issue, its potential impact, and recommended mitigation actions.
Users are strongly encouraged to evaluate these advisories and consider the recommended mitigation actions independently from their version upgrade schedule.
We understand that these advisories may include breaking changes, and we aim to provide clear guidance on how to address these changes.
<table>
<tr>
<th>Advisory</th>
<th>Name</th>
<th>Type</th>
<th>Summary</th>
<th>Affected versions</th>
<th>Date</th>
</tr>
<tr>
<td><a href="./advisory/a10000">A-10000</a></td>
<td>Reusing user session</td>
<td>Breaking Behaviour Change</td>
<td>The default behavior for users logging in is to be directed to the Select Account Page on the Login. With the upcoming changes, users will be automatically authenticated when logging into a second application, as long as they only have one active session. No action is required on your part if this is the intended behavior.</td>
<td>TBD</td>
<td>TBD</td>
</tr>
</table>
## Categories
### Breaking Behaviour Change
A breaking behavior change refers to a modification or update that changes the behavior of ZITADEL.
This change does not necessarily affect the APIs or any functions you are calling, so it may not require an update to your code.
However, if you rely on specific results or behaviors, they may no longer be guaranteed after the change is implemented.
Therefore, it is important to be aware of breaking behavior changes and their potential impact on your use of ZITADEL, and to take appropriate action if needed to ensure continued functionality.

View File

@ -266,6 +266,20 @@ module.exports = {
sidebarOptions: { sidebarOptions: {
groupPathsBy: "tag", groupPathsBy: "tag",
}, },
},
session: {
specPath: ".artifacts/openapi/zitadel/session/v2alpha/session_service.swagger.json",
outputDir: "docs/apis/session_service",
sidebarOptions: {
groupPathsBy: "tag",
},
},
settings: {
specPath: ".artifacts/openapi/zitadel/settings/v2alpha/settings_service.swagger.json",
outputDir: "docs/apis/settings_service",
sidebarOptions: {
groupPathsBy: "tag",
},
} }
} }
}, },

View File

@ -175,9 +175,16 @@ module.exports = {
{ {
type: "category", type: "category",
label: "Authenticate Service Users", label: "Authenticate Service Users",
link: {
type: "generated-index",
title: "Authenticate Service Users",
slug: "/guides/integrate/serviceusers",
description:
"How to authenticate service users",
},
collapsed: true, collapsed: true,
items: [ items: [
"guides/integrate/serviceusers", "guides/integrate/private-key-jwt",
"guides/integrate/client-credentials", "guides/integrate/client-credentials",
"guides/integrate/pat", "guides/integrate/pat",
], ],
@ -274,6 +281,7 @@ module.exports = {
"concepts/features/identity-brokering", "concepts/features/identity-brokering",
"concepts/structure/jwt_idp", "concepts/structure/jwt_idp",
"concepts/features/actions", "concepts/features/actions",
"concepts/features/audit-trail",
"concepts/features/selfservice", "concepts/features/selfservice",
] ]
}, },
@ -303,6 +311,21 @@ module.exports = {
collapsed: true, collapsed: true,
items: [ items: [
"support/troubleshooting", "support/troubleshooting",
{
type: 'category',
label: "Technical Advisory",
link: {
type: 'doc',
id: 'support/technical_advisory',
},
collapsed: true,
items: [
{
type: 'autogenerated',
dirName: 'support/advisory',
},
],
},
{ {
type: "category", type: "category",
label: "Trainings", label: "Trainings",
@ -389,6 +412,34 @@ module.exports = {
}, },
items: require("./docs/apis/user_service/sidebar.js"), items: require("./docs/apis/user_service/sidebar.js"),
}, },
{
type: "category",
label: "Session Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "Session Service API (Alpha)",
slug: "/apis/session_service",
description:
"This API is intended to manage sessions in a ZITADEL instance.\n"+
"\n"+
"This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/session_service/sidebar.js"),
},
{
type: "category",
label: "Settings Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "Settings Service API (Alpha)",
slug: "/apis/settings_service",
description:
"This API is intended to manage settings in a ZITADEL instance.\n"+
"\n"+
"This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/settings_service/sidebar.js"),
},
{ {
type: "category", type: "category",
label: "Assets", label: "Assets",

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

@ -2,7 +2,8 @@ import { Apps, ensureProjectExists, ensureProjectResourceDoesntExist } from '../
import { Context } from 'support/commands'; import { Context } from 'support/commands';
const testProjectName = 'e2eprojectapplication'; const testProjectName = 'e2eprojectapplication';
const testAppName = 'e2eappundertest'; const testPKCEAppName = 'e2eapppkcetest';
const testDEVICECODEAppName = 'e2eappdevicecodetest';
describe('applications', () => { describe('applications', () => {
beforeEach(() => { beforeEach(() => {
@ -17,15 +18,15 @@ describe('applications', () => {
beforeEach(`ensure it doesn't exist already`, () => { beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => { cy.get<Context>('@ctx').then((ctx) => {
cy.get<string>('@projectId').then((projectId) => { cy.get<string>('@projectId').then((projectId) => {
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testAppName); ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testPKCEAppName);
cy.visit(`/projects/${projectId}`); cy.visit(`/projects/${projectId}`);
}); });
}); });
}); });
it('add app', () => { it('add web pkce app', () => {
cy.get('[data-e2e="app-card-add"]').should('be.visible').click(); cy.get('[data-e2e="app-card-add"]').should('be.visible').click();
cy.get('[formcontrolname="name"]').focus().type(testAppName); cy.get('[formcontrolname="name"]').focus().type(testPKCEAppName);
cy.get('[for="WEB"]').click(); cy.get('[for="WEB"]').click();
cy.get('[data-e2e="continue-button-nameandtype"]').click(); cy.get('[data-e2e="continue-button-nameandtype"]').click();
cy.get('[for="PKCE"]').should('be.visible').click(); cy.get('[for="PKCE"]').should('be.visible').click();
@ -43,6 +44,33 @@ describe('applications', () => {
}); });
}); });
describe('add native device code app', () => {
beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => {
cy.get<string>('@projectId').then((projectId) => {
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testDEVICECODEAppName);
cy.visit(`/projects/${projectId}`);
});
});
});
it('add device code app', () => {
cy.get('[data-e2e="app-card-add"]').should('be.visible').click();
cy.get('[formcontrolname="name"]').focus().type(testDEVICECODEAppName);
cy.get('[for="N"]').click();
cy.get('[data-e2e="continue-button-nameandtype"]').click();
cy.get('[for="DEVICECODE"]').should('be.visible').click();
cy.get('[data-e2e="continue-button-authmethod"]').click();
cy.get('[data-e2e="create-button"]').click();
cy.get('[id*=overlay]').should('exist');
cy.shouldConfirmSuccess();
const expectClientId = new RegExp(`^.*[0-9]+\\@${testProjectName}.*$`);
cy.get('[data-e2e="client-id-copy"]').click();
cy.contains('[data-e2e="client-id"]', expectClientId);
cy.clipboardMatches(expectClientId);
});
});
describe('edit app', () => { describe('edit app', () => {
it('should configure an application to enable dev mode'); it('should configure an application to enable dev mode');
it('should configure an application to put user roles and info inside id token'); it('should configure an application to put user roles and info inside id token');

View File

@ -107,14 +107,9 @@ describe('quotas', () => {
}, },
}); });
}); });
expectCookieDoesntExist();
const expiresMax = new Date(); const expiresMax = new Date();
expiresMax.setMinutes(expiresMax.getMinutes() + 2); expiresMax.setMinutes(expiresMax.getMinutes() + 2);
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
expect(cookie.value).to.equal('false');
const cookieExpiry = new Date();
cookieExpiry.setTime(cookie.expiry * 1000);
expect(cookieExpiry).to.be.within(start, expiresMax);
});
cy.request({ cy.request({
url: urls[0], url: urls[0],
method: 'GET', method: 'GET',
@ -127,12 +122,16 @@ describe('quotas', () => {
}); });
cy.getCookie('zitadel.quota.limiting').then((cookie) => { cy.getCookie('zitadel.quota.limiting').then((cookie) => {
expect(cookie.value).to.equal('true'); expect(cookie.value).to.equal('true');
const cookieExpiry = new Date();
cookieExpiry.setTime(cookie.expiry * 1000);
expect(cookieExpiry).to.be.within(start, expiresMax);
}); });
createHumanUser(ctx.api, testUserName, false).then((res) => { createHumanUser(ctx.api, testUserName, false).then((res) => {
expect(res.status).to.equal(429); expect(res.status).to.equal(429);
}); });
ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests); ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests);
createHumanUser(ctx.api, testUserName); createHumanUser(ctx.api, testUserName);
expectCookieDoesntExist();
}); });
}); });
}); });
@ -301,3 +300,9 @@ describe('quotas', () => {
}); });
}); });
}); });
function expectCookieDoesntExist() {
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
expect(cookie).to.be.null;
});
}

2
go.mod
View File

@ -192,7 +192,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect
go.opentelemetry.io/proto/otlp v0.19.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect
golang.org/x/mod v0.10.0 // indirect golang.org/x/mod v0.10.0 // indirect
golang.org/x/sys v0.7.0 // indirect golang.org/x/sys v0.7.0
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

View File

@ -16,6 +16,7 @@ import (
internal_authz "github.com/zitadel/zitadel/internal/api/authz" internal_authz "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
http_util "github.com/zitadel/zitadel/internal/api/http" http_util "github.com/zitadel/zitadel/internal/api/http"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore"
@ -33,6 +34,9 @@ type API struct {
http1HostName string http1HostName string
grpcGateway *server.Gateway grpcGateway *server.Gateway
healthServer *health.Server healthServer *health.Server
cookieHandler *http_util.CookieHandler
cookieConfig *http_mw.AccessConfig
queries *query.Queries
} }
type healthCheck interface { type healthCheck interface {
@ -48,6 +52,8 @@ func New(
authZ internal_authz.Config, authZ internal_authz.Config,
tlsConfig *tls.Config, http2HostName, http1HostName string, tlsConfig *tls.Config, http2HostName, http1HostName string,
accessSvc *logstore.Service, accessSvc *logstore.Service,
cookieHandler *http_util.CookieHandler,
cookieConfig *http_mw.AccessConfig,
) (_ *API, err error) { ) (_ *API, err error) {
api := &API{ api := &API{
port: port, port: port,
@ -55,10 +61,13 @@ func New(
health: queries, health: queries,
router: router, router: router,
http1HostName: http1HostName, http1HostName: http1HostName,
cookieConfig: cookieConfig,
cookieHandler: cookieHandler,
queries: queries,
} }
api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc) api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc)
api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName) api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, cookieHandler, cookieConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -76,7 +85,15 @@ func New(
// used for v1 api (system, admin, mgmt, auth) // used for v1 api (system, admin, mgmt, auth)
func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix) error { func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix) error {
grpcServer.RegisterServer(a.grpcServer) grpcServer.RegisterServer(a.grpcServer)
handler, prefix, err := server.CreateGatewayWithPrefix(ctx, grpcServer, a.port, a.http1HostName) handler, prefix, err := server.CreateGatewayWithPrefix(
ctx,
grpcServer,
a.port,
a.http1HostName,
a.cookieHandler,
a.cookieConfig,
a.queries,
)
if err != nil { if err != nil {
return err return err
} }
@ -167,6 +184,7 @@ func (a *API) routeGRPCWeb() {
return true return true
}), }),
) )
a.router.Use(http_mw.RobotsTagHandler)
a.router.NewRoute(). a.router.NewRoute().
Methods(http.MethodPost, http.MethodOptions). Methods(http.MethodPost, http.MethodOptions).
MatcherFunc( MatcherFunc(

View File

@ -14,11 +14,16 @@ const (
authenticated = "authenticated" authenticated = "authenticated"
) )
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { // CheckUserAuthorization verifies that:
// - the token is active,
// - the organisation (**either** provided by ID or verified domain) exists
// - the user is permitted to call the requested endpoint (permission option in proto)
// it will pass the [CtxData] and permission of the user into the ctx [context.Context]
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
ctx, span := tracing.NewServerInterceptorSpan(ctx) ctx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method) ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, method)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -60,7 +60,7 @@ const (
MemberTypeIam MemberTypeIam
) )
func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *TokenVerifier, method string) (_ CtxData, err error) { func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t *TokenVerifier, method string) (_ CtxData, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
@ -82,14 +82,15 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To
if err := checkOrigin(ctx, origins); err != nil { if err := checkOrigin(ctx, origins); err != nil {
return CtxData{}, err return CtxData{}, err
} }
if orgID == "" { if orgID == "" && orgDomain == "" {
orgID = resourceOwner orgID = resourceOwner
} }
err = t.ExistsOrg(ctx, orgID) verifiedOrgID, err := t.ExistsOrg(ctx, orgID, orgDomain)
if err != nil { if err != nil {
err = retry(func() error { err = retry(func() error {
return t.ExistsOrg(ctx, orgID) verifiedOrgID, err = t.ExistsOrg(ctx, orgID, orgDomain)
return err
}) })
if err != nil { if err != nil {
return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist") return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
@ -98,7 +99,7 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To
return CtxData{ return CtxData{
UserID: userID, UserID: userID,
OrgID: orgID, OrgID: verifiedOrgID,
ProjectID: projectID, ProjectID: projectID,
AgentID: agentID, AgentID: agentID,
PreferredLanguage: prefLang, PreferredLanguage: prefLang,

View File

@ -7,12 +7,8 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
) )
func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) { func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) {
ctxData := GetCtxData(ctx) requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, GetCtxData(ctx), orgID)
if allowSelf && ctxData.UserID == resourceID {
return nil
}
requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID)
if err != nil { if err != nil {
return err return err
} }

View File

@ -26,8 +26,8 @@ func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, client
return "", nil, nil return "", nil, nil
} }
func (v *testVerifier) ExistsOrg(ctx context.Context, orgID string) error { func (v *testVerifier) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) {
return nil return orgID, nil
} }
func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) { func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) {

View File

@ -3,6 +3,8 @@ package authz
import ( import (
"context" "context"
"crypto/rsa" "crypto/rsa"
"encoding/base64"
"fmt"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -18,6 +20,7 @@ import (
const ( const (
BearerPrefix = "Bearer " BearerPrefix = "Bearer "
SessionTokenFormat = "sess_%s:%s"
) )
type TokenVerifier struct { type TokenVerifier struct {
@ -36,7 +39,7 @@ type authZRepo interface {
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error) VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
ExistsOrg(ctx context.Context, orgID string) error ExistsOrg(ctx context.Context, id, domain string) (string, error)
} }
func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) { func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) {
@ -144,10 +147,10 @@ func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clien
return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID) return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID)
} }
func (v *TokenVerifier) ExistsOrg(ctx context.Context, orgID string) (err error) { func (v *TokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
return v.authZRepo.ExistsOrg(ctx, orgID) return v.authZRepo.ExistsOrg(ctx, id, domain)
} }
func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) { func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) {
@ -165,3 +168,20 @@ func verifyAccessToken(ctx context.Context, token string, t *TokenVerifier, meth
} }
return t.VerifyAccessToken(ctx, parts[1], method) return t.VerifyAccessToken(ctx, parts[1], method)
} }
func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken)
if err != nil {
return err
}
_, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
var token string
token, err = algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID())
spanPasswordComparison.EndWithError(err)
if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) {
return caos_errs.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
}
return nil
}
}

View File

@ -0,0 +1,36 @@
//go:build integration
package admin_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/admin"
)
var (
Tester *integration.Tester
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, _, cancel := integration.Contexts(time.Minute)
defer cancel()
Tester = integration.NewTester(ctx)
defer Tester.Done()
return m.Run()
}())
}
func TestServer_Healthz(t *testing.T) {
client := admin.NewAdminServiceClient(Tester.GRPCClientConn)
_, err := client.Healthz(context.TODO(), &admin.HealthzRequest{})
require.NoError(t, err)
}

View File

@ -1,9 +1,13 @@
package object package object
import ( import (
"context"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
) )
@ -17,3 +21,31 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
} }
return details return details
} }
func ToListDetails(response query.SearchResponse) *object.ListDetails {
details := &object.ListDetails{
TotalResult: response.Count,
ProcessedSequence: response.Sequence,
}
if !response.Timestamp.IsZero() {
details.Timestamp = timestamppb.New(response.Timestamp)
}
return details
}
func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) {
if query == nil {
return 0, 0, false
}
return query.Offset, uint64(query.Limit), query.Asc
}
func ResourceOwnerFromReq(ctx context.Context, req *object.RequestContext) string {
if req.GetInstance() {
return authz.GetInstance(ctx).InstanceID()
}
if req.GetOrgId() != "" {
return req.GetOrgId()
}
return authz.GetCtxData(ctx).OrgID
}

View File

@ -12,10 +12,13 @@ import (
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1" healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware" client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
http_utils "github.com/zitadel/zitadel/internal/api/http"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/query"
) )
const ( const (
@ -38,6 +41,7 @@ var (
runtime.WithMarshalerOption(runtime.MIMEWildcard, jsonMarshaler), runtime.WithMarshalerOption(runtime.MIMEWildcard, jsonMarshaler),
runtime.WithIncomingHeaderMatcher(headerMatcher), runtime.WithIncomingHeaderMatcher(headerMatcher),
runtime.WithOutgoingHeaderMatcher(runtime.DefaultHeaderMatcher), runtime.WithOutgoingHeaderMatcher(runtime.DefaultHeaderMatcher),
runtime.WithForwardResponseOption(responseForwarder),
} }
headerMatcher = runtime.HeaderMatcherFunc( headerMatcher = runtime.HeaderMatcherFunc(
@ -50,21 +54,45 @@ var (
return runtime.DefaultHeaderMatcher(header) return runtime.DefaultHeaderMatcher(header)
}, },
) )
responseForwarder = func(ctx context.Context, w http.ResponseWriter, resp proto.Message) error {
t, ok := resp.(CustomHTTPResponse)
if ok {
// TODO: find a way to return a location header if needed w.Header().Set("location", t.Location())
w.WriteHeader(t.CustomHTTPCode())
}
return nil
}
) )
type Gateway struct { type Gateway struct {
mux *runtime.ServeMux mux *runtime.ServeMux
http1HostName string http1HostName string
connection *grpc.ClientConn connection *grpc.ClientConn
cookieHandler *http_utils.CookieHandler
cookieConfig *http_mw.AccessConfig
queries *query.Queries
} }
func (g *Gateway) Handler() http.Handler { func (g *Gateway) Handler() http.Handler {
return addInterceptors(g.mux, g.http1HostName) return addInterceptors(g.mux, g.http1HostName, g.cookieHandler, g.cookieConfig, g.queries)
}
type CustomHTTPResponse interface {
CustomHTTPCode() int
} }
type RegisterGatewayFunc func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error type RegisterGatewayFunc func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error
func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint16, http1HostName string) (http.Handler, string, error) { func CreateGatewayWithPrefix(
ctx context.Context,
g WithGatewayPrefix,
port uint16,
http1HostName string,
cookieHandler *http_utils.CookieHandler,
cookieConfig *http_mw.AccessConfig,
queries *query.Queries,
) (http.Handler, string, error) {
runtimeMux := runtime.NewServeMux(serveMuxOptions...) runtimeMux := runtime.NewServeMux(serveMuxOptions...)
opts := []grpc.DialOption{ opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
@ -78,10 +106,10 @@ func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err) return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err)
} }
return addInterceptors(runtimeMux, http1HostName), g.GatewayPathPrefix(), nil return addInterceptors(runtimeMux, http1HostName, cookieHandler, cookieConfig, queries), g.GatewayPathPrefix(), nil
} }
func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gateway, error) { func CreateGateway(ctx context.Context, port uint16, http1HostName string, cookieHandler *http_utils.CookieHandler, cookieConfig *http_mw.AccessConfig) (*Gateway, error) {
connection, err := dial(ctx, connection, err := dial(ctx,
port, port,
[]grpc.DialOption{ []grpc.DialOption{
@ -96,6 +124,8 @@ func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gat
mux: runtimeMux, mux: runtimeMux,
http1HostName: http1HostName, http1HostName: http1HostName,
connection: connection, connection: connection,
cookieHandler: cookieHandler,
cookieConfig: cookieConfig,
}, nil }, nil
} }
@ -130,12 +160,23 @@ func dial(ctx context.Context, port uint16, opts []grpc.DialOption) (*grpc.Clien
return conn, nil return conn, nil
} }
func addInterceptors(handler http.Handler, http1HostName string) http.Handler { func addInterceptors(
handler http.Handler,
http1HostName string,
cookieHandler *http_utils.CookieHandler,
cookieConfig *http_mw.AccessConfig,
queries *query.Queries,
) http.Handler {
handler = http_mw.CallDurationHandler(handler) handler = http_mw.CallDurationHandler(handler)
handler = http1Host(handler, http1HostName) handler = http1Host(handler, http1HostName)
handler = http_mw.CORSInterceptor(handler) handler = http_mw.CORSInterceptor(handler)
handler = http_mw.RobotsTagHandler(handler)
handler = http_mw.DefaultTelemetryHandler(handler) handler = http_mw.DefaultTelemetryHandler(handler)
return http_mw.DefaultMetricsHandler(handler) // For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header
// only if it follows the http_mw.DefaultTelemetryHandler
handler = exhaustedCookieInterceptor(handler, cookieHandler, cookieConfig, queries)
handler = http_mw.DefaultMetricsHandler(handler)
return handler
} }
func http1Host(next http.Handler, http1HostName string) http.Handler { func http1Host(next http.Handler, http1HostName string) http.Handler {
@ -149,3 +190,38 @@ func http1Host(next http.Handler, http1HostName string) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
func exhaustedCookieInterceptor(
next http.Handler,
cookieHandler *http_utils.CookieHandler,
cookieConfig *http_mw.AccessConfig,
queries *query.Queries,
) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
next.ServeHTTP(&cookieResponseWriter{
ResponseWriter: writer,
cookieHandler: cookieHandler,
cookieConfig: cookieConfig,
request: request,
queries: queries,
}, request)
})
}
type cookieResponseWriter struct {
http.ResponseWriter
cookieHandler *http_utils.CookieHandler
cookieConfig *http_mw.AccessConfig
request *http.Request
queries *query.Queries
}
func (r *cookieResponseWriter) WriteHeader(status int) {
if status >= 200 && status < 300 {
http_mw.DeleteExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.request, r.cookieConfig)
}
if status == http.StatusTooManyRequests {
http_mw.SetExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.cookieConfig, r.request)
}
r.ResponseWriter.WriteHeader(status)
}

View File

@ -11,6 +11,7 @@ import (
grpc_util "github.com/zitadel/zitadel/internal/api/grpc" grpc_util "github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
) )
func AuthorizationInterceptor(verifier *authz.TokenVerifier, authConfig authz.Config) grpc.UnaryServerInterceptor { func AuthorizationInterceptor(verifier *authz.TokenVerifier, authConfig authz.Config) grpc.UnaryServerInterceptor {
@ -33,12 +34,14 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
return nil, status.Error(codes.Unauthenticated, "auth header missing") return nil, status.Error(codes.Unauthenticated, "auth header missing")
} }
var orgDomain string
orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID) orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID)
if o, ok := req.(AuthContext); ok { if o, ok := req.(OrganisationFromRequest); ok {
orgID = o.AuthContext() orgID = o.OrganisationFromRequest().GetOrgId()
orgDomain = o.OrganisationFromRequest().GetOrgDomain()
} }
ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod) ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, authConfig, authOpt, info.FullMethod)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -46,6 +49,6 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
return handler(ctxSetter(ctx), req) return handler(ctxSetter(ctx), req)
} }
type AuthContext interface { type OrganisationFromRequest interface {
AuthContext() string OrganisationFromRequest() *object.Organisation
} }

View File

@ -31,8 +31,8 @@ func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([
func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) {
return "", nil, nil return "", nil, nil
} }
func (v *verifierMock) ExistsOrg(ctx context.Context, orgID string) error { func (v *verifierMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) {
return nil return orgID, nil
} }
func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
return "", "", nil return "", "", nil

View File

@ -6,8 +6,9 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
) )
var _ session.SessionServiceServer = (*Server)(nil) var _ session.SessionServiceServer = (*Server)(nil)
@ -16,6 +17,7 @@ type Server struct {
session.UnimplementedSessionServiceServer session.UnimplementedSessionServiceServer
command *command.Commands command *command.Commands
query *query.Queries query *query.Queries
checkPermission domain.PermissionCheck
} }
type Config struct{} type Config struct{}
@ -23,10 +25,12 @@ type Config struct{}
func CreateServer( func CreateServer(
command *command.Commands, command *command.Commands,
query *query.Queries, query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server { ) *Server {
return &Server{ return &Server{
command: command, command: command,
query: query, query: query,
checkPermission: checkPermission,
} }
} }

View File

@ -3,16 +3,260 @@ package session
import ( import (
"context" "context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" "github.com/zitadel/zitadel/internal/command"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
) )
func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
res, err := s.query.SessionByID(ctx, req.GetSessionId(), req.GetSessionToken())
if err != nil {
return nil, err
}
return &session.GetSessionResponse{ return &session.GetSessionResponse{
Session: &session.Session{ Session: sessionToPb(res),
Id: req.Id,
User: &user.User{Id: authz.GetCtxData(ctx).UserID},
},
}, nil }, nil
} }
func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) {
queries, err := listSessionsRequestToQuery(ctx, req)
if err != nil {
return nil, err
}
sessions, err := s.query.SearchSessions(ctx, queries)
if err != nil {
return nil, err
}
return &session.ListSessionsResponse{
Details: object.ToListDetails(sessions.SearchResponse),
Sessions: sessionsToPb(sessions.Sessions),
}, nil
}
func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) {
checks, metadata, err := s.createSessionRequestToCommand(ctx, req)
if err != nil {
return nil, err
}
set, err := s.command.CreateSession(ctx, checks, metadata)
if err != nil {
return nil, err
}
return &session.CreateSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionId: set.ID,
SessionToken: set.NewToken,
}, nil
}
func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) {
checks, err := s.setSessionRequestToCommand(ctx, req)
if err != nil {
return nil, err
}
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata())
if err != nil {
return nil, err
}
// if there's no new token, just return the current
if set.NewToken == "" {
set.NewToken = req.GetSessionToken()
}
return &session.SetSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionToken: set.NewToken,
}, nil
}
func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) {
details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken())
if err != nil {
return nil, err
}
return &session.DeleteSessionResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}
func sessionsToPb(sessions []*query.Session) []*session.Session {
s := make([]*session.Session, len(sessions))
for i, session := range sessions {
s[i] = sessionToPb(session)
}
return s
}
func sessionToPb(s *query.Session) *session.Session {
return &session.Session{
Id: s.ID,
CreationDate: timestamppb.New(s.CreationDate),
ChangeDate: timestamppb.New(s.ChangeDate),
Sequence: s.Sequence,
Factors: factorsToPb(s),
Metadata: s.Metadata,
}
}
func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor)
pw := passwordFactorToPb(s.PasswordFactor)
if user == nil && pw == nil {
return nil
}
return &session.Factors{
User: user,
Password: pw,
}
}
func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor {
if factor.PasswordCheckedAt.IsZero() {
return nil
}
return &session.PasswordFactor{
VerifiedAt: timestamppb.New(factor.PasswordCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
}
return &session.UserFactor{
VerifiedAt: timestamppb.New(factor.UserCheckedAt),
Id: factor.UserID,
LoginName: factor.LoginName,
DisplayName: factor.DisplayName,
}
}
func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) {
offset, limit, asc := object.ListQueryToQuery(req.Query)
queries, err := sessionQueriesToQuery(ctx, req.GetQueries())
if err != nil {
return nil, err
}
return &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
},
Queries: queries,
}, nil
}
func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) {
q := make([]query.SearchQuery, len(queries)+1)
for i, query := range queries {
q[i], err = sessionQueryToQuery(query)
if err != nil {
return nil, err
}
}
creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID)
if err != nil {
return nil, err
}
q[len(queries)] = creatorQuery
return q, nil
}
func sessionQueryToQuery(query *session.SearchQuery) (query.SearchQuery, error) {
switch q := query.Query.(type) {
case *session.SearchQuery_IdsQuery:
return idsQueryToQuery(q.IdsQuery)
default:
return nil, caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid")
}
}
func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids)
}
func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, nil, err
}
return checks, req.GetMetadata(), nil
}
func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, err
}
return checks, nil
}
func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) {
checkUser, err := userCheck(checks.GetUser())
if err != nil {
return nil, err
}
sessionChecks := make([]command.SessionCheck, 0, 2)
if checkUser != nil {
user, err := checkUser.search(ctx, s.query)
if err != nil {
return nil, err
}
sessionChecks = append(sessionChecks, command.CheckUser(user.ID))
}
if password := checks.GetPassword(); password != nil {
sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
}
return sessionChecks, nil
}
func userCheck(user *session.CheckUser) (userSearch, error) {
if user == nil {
return nil, nil
}
switch s := user.GetSearch().(type) {
case *session.CheckUser_UserId:
return userByID(s.UserId), nil
case *session.CheckUser_LoginName:
return userByLoginName(s.LoginName)
default:
return nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", s)
}
}
type userSearch interface {
search(ctx context.Context, q *query.Queries) (*query.User, error)
}
func userByID(userID string) userSearch {
return userSearchByID{userID}
}
func userByLoginName(loginName string) (userSearch, error) {
loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName)
if err != nil {
return nil, err
}
return userSearchByLoginName{loginNameQuery}, nil
}
type userSearchByID struct {
id string
}
func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.User, error) {
return q.GetUserByID(ctx, true, u.id, false)
}
type userSearchByLoginName struct {
loginNameQuery query.SearchQuery
}
func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) {
return q.GetUser(ctx, true, false, u.loginNameQuery)
}

View File

@ -0,0 +1,379 @@
package session
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
)
func Test_sessionsToPb(t *testing.T) {
now := time.Now()
past := now.Add(-time.Hour)
sessions := []*query.Session{
{ // no factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // user factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // no factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
PasswordFactor: query.SessionPasswordFactor{
PasswordCheckedAt: past,
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
want := []*session.Session{
{ // no factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: nil,
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // user factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // password factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
Password: &session.PasswordFactor{
VerifiedAt: timestamppb.New(past),
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
out := sessionsToPb(sessions)
require.Len(t, out, len(want))
for i, got := range out {
if !proto.Equal(got, want[i]) {
t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want)
}
}
}
func mustNewTextQuery(t testing.TB, column query.Column, value string, compare query.TextComparison) query.SearchQuery {
q, err := query.NewTextQuery(column, value, compare)
require.NoError(t, err)
return q
}
func mustNewListQuery(t testing.TB, column query.Column, list []any, compare query.ListComparison) query.SearchQuery {
q, err := query.NewListQuery(query.SessionColumnID, list, compare)
require.NoError(t, err)
return q
}
func Test_listSessionsRequestToQuery(t *testing.T) {
type args struct {
ctx context.Context
req *session.ListSessionsRequest
}
tests := []struct {
name string
args args
want *query.SessionsSearchQueries
wantErr error
}{
{
name: "default request",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{},
},
want: &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 0,
Asc: false,
},
Queries: []query.SearchQuery{
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
},
{
name: "with list query and sessions",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{
Query: &object.ListQuery{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"4", "5", "6"},
},
}},
},
},
},
want: &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []query.SearchQuery{
mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
},
{
name: "invalid argument error",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{
Query: &object.ListQuery{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: nil},
},
},
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := listSessionsRequestToQuery(tt.args.ctx, tt.args.req)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func Test_sessionQueriesToQuery(t *testing.T) {
type args struct {
ctx context.Context
queries []*session.SearchQuery
}
tests := []struct {
name string
args args
want []query.SearchQuery
wantErr error
}{
{
name: "creator only",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
},
want: []query.SearchQuery{
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
{
name: "invalid argument",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
queries: []*session.SearchQuery{
{Query: nil},
},
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
{
name: "creator and sessions",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"4", "5", "6"},
},
}},
},
},
want: []query.SearchQuery{
mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sessionQueriesToQuery(tt.args.ctx, tt.args.queries)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func Test_sessionQueryToQuery(t *testing.T) {
type args struct {
query *session.SearchQuery
}
tests := []struct {
name string
args args
want query.SearchQuery
wantErr error
}{
{
name: "invalid argument",
args: args{&session.SearchQuery{
Query: nil,
}},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
{
name: "query",
args: args{&session.SearchQuery{
Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
},
}},
want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sessionQueryToQuery(tt.args.query)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func mustUserLoginNamesSearchQuery(t testing.TB, value string) query.SearchQuery {
loginNameQuery, err := query.NewUserLoginNamesSearchQuery("bar")
require.NoError(t, err)
return loginNameQuery
}
func Test_userCheck(t *testing.T) {
type args struct {
user *session.CheckUser
}
tests := []struct {
name string
args args
want userSearch
wantErr error
}{
{
name: "nil user",
args: args{nil},
want: nil,
},
{
name: "by user id",
args: args{&session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: "foo",
},
}},
want: userSearchByID{"foo"},
},
{
name: "by user id",
args: args{&session.CheckUser{
Search: &session.CheckUser_LoginName{
LoginName: "bar",
},
}},
want: userSearchByLoginName{mustUserLoginNamesSearchQuery(t, "bar")},
},
{
name: "unimplemented error",
args: args{&session.CheckUser{
Search: nil,
}},
wantErr: caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := userCheck(tt.args.user)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -0,0 +1,57 @@
package settings
import (
"context"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/assets"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
)
var _ settings.SettingsServiceServer = (*Server)(nil)
type Server struct {
settings.UnimplementedSettingsServiceServer
command *command.Commands
query *query.Queries
assetsAPIDomain func(context.Context) string
}
type Config struct{}
func CreateServer(
command *command.Commands,
query *query.Queries,
externalSecure bool,
) *Server {
return &Server{
command: command,
query: query,
assetsAPIDomain: assets.AssetAPI(externalSecure),
}
}
func (s *Server) RegisterServer(grpcServer *grpc.Server) {
settings.RegisterSettingsServiceServer(grpcServer, s)
}
func (s *Server) AppName() string {
return settings.SettingsService_ServiceDesc.ServiceName
}
func (s *Server) MethodPrefix() string {
return settings.SettingsService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {
return settings.SettingsService_AuthMethods
}
func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
return settings.RegisterSettingsServiceHandler
}

View File

@ -0,0 +1,129 @@
package settings
import (
"context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/api/grpc/text"
"github.com/zitadel/zitadel/internal/query"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
)
func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) {
current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetLoginSettingsResponse{
Settings: loginSettingsToPb(current),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.OrgID,
},
}, nil
}
func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) {
current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetPasswordComplexitySettingsResponse{
Settings: passwordSettingsToPb(current),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.ResourceOwner,
},
}, nil
}
func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) {
current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetBrandingSettingsResponse{
Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.ResourceOwner,
},
}, nil
}
func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) {
current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetDomainSettingsResponse{
Settings: domainSettingsToPb(current),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.ResourceOwner,
},
}, nil
}
func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) {
current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetLegalAndSupportSettingsResponse{
Settings: legalAndSupportSettingsToPb(current),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.ResourceOwner,
},
}, nil
}
func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) {
current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
if err != nil {
return nil, err
}
return &settings.GetLockoutSettingsResponse{
Settings: lockoutSettingsToPb(current),
Details: &object_pb.Details{
Sequence: current.Sequence,
ChangeDate: timestamppb.New(current.ChangeDate),
ResourceOwner: current.ResourceOwner,
},
}, nil
}
func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) {
links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false)
if err != nil {
return nil, err
}
return &settings.GetActiveIdentityProvidersResponse{
Details: object.ToListDetails(links.SearchResponse),
IdentityProviders: identityProvidersToPb(links.Links),
}, nil
}
func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) {
langs, err := s.query.Languages(ctx)
if err != nil {
return nil, err
}
instance := authz.GetInstance(ctx)
return &settings.GetGeneralSettingsResponse{
SupportedLanguages: text.LanguageTagsToStrings(langs),
DefaultOrgId: instance.DefaultOrganisationID(),
DefaultLanguage: instance.DefaultLanguage().String(),
}, nil
}

View File

@ -0,0 +1,189 @@
package settings
import (
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
)
// TODO: ?
func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings {
multi := make([]settings.MultiFactorType, len(current.MultiFactors))
for i, typ := range current.MultiFactors {
multi[i] = multiFactorTypeToPb(typ)
}
second := make([]settings.SecondFactorType, len(current.SecondFactors))
for i, typ := range current.SecondFactors {
second[i] = secondFactorTypeToPb(typ)
}
return &settings.LoginSettings{
AllowUsernamePassword: current.AllowUsernamePassword,
AllowRegister: current.AllowRegister,
AllowExternalIdp: current.AllowExternalIDPs,
ForceMfa: current.ForceMFA,
PasskeysType: passkeysTypeToPb(current.PasswordlessType),
HidePasswordReset: current.HidePasswordReset,
IgnoreUnknownUsernames: current.IgnoreUnknownUsernames,
AllowDomainDiscovery: current.AllowDomainDiscovery,
DisableLoginWithEmail: current.DisableLoginWithEmail,
DisableLoginWithPhone: current.DisableLoginWithPhone,
DefaultRedirectUri: current.DefaultRedirectURI,
PasswordCheckLifetime: durationpb.New(current.PasswordCheckLifetime),
ExternalLoginCheckLifetime: durationpb.New(current.ExternalLoginCheckLifetime),
MfaInitSkipLifetime: durationpb.New(current.MFAInitSkipLifetime),
SecondFactorCheckLifetime: durationpb.New(current.SecondFactorCheckLifetime),
MultiFactorCheckLifetime: durationpb.New(current.MultiFactorCheckLifetime),
SecondFactors: second,
MultiFactors: multi,
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func isDefaultToResourceOwnerTypePb(isDefault bool) settings.ResourceOwnerType {
if isDefault {
return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE
}
return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG
}
func passkeysTypeToPb(passwordlessType domain.PasswordlessType) settings.PasskeysType {
switch passwordlessType {
case domain.PasswordlessTypeAllowed:
return settings.PasskeysType_PASSKEYS_TYPE_ALLOWED
case domain.PasswordlessTypeNotAllowed:
return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
default:
return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
}
}
func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.SecondFactorType {
switch secondFactorType {
case domain.SecondFactorTypeOTP:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP
case domain.SecondFactorTypeU2F:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F
case domain.SecondFactorTypeUnspecified:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
default:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
}
}
func multiFactorTypeToPb(typ domain.MultiFactorType) settings.MultiFactorType {
switch typ {
case domain.MultiFactorTypeU2FWithPIN:
return settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION
case domain.MultiFactorTypeUnspecified:
return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
default:
return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
}
}
func passwordSettingsToPb(current *query.PasswordComplexityPolicy) *settings.PasswordComplexitySettings {
return &settings.PasswordComplexitySettings{
MinLength: current.MinLength,
RequiresUppercase: current.HasUppercase,
RequiresLowercase: current.HasLowercase,
RequiresNumber: current.HasNumber,
RequiresSymbol: current.HasSymbol,
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func brandingSettingsToPb(current *query.LabelPolicy, assetPrefix string) *settings.BrandingSettings {
return &settings.BrandingSettings{
LightTheme: themeToPb(current.Light, assetPrefix, current.ResourceOwner),
DarkTheme: themeToPb(current.Dark, assetPrefix, current.ResourceOwner),
FontUrl: domain.AssetURL(assetPrefix, current.ResourceOwner, current.FontURL),
DisableWatermark: current.WatermarkDisabled,
HideLoginNameSuffix: current.HideLoginNameSuffix,
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func themeToPb(theme query.Theme, assetPrefix, resourceOwner string) *settings.Theme {
return &settings.Theme{
PrimaryColor: theme.PrimaryColor,
BackgroundColor: theme.BackgroundColor,
FontColor: theme.FontColor,
WarnColor: theme.WarnColor,
LogoUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.LogoURL),
IconUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.IconURL),
}
}
func domainSettingsToPb(current *query.DomainPolicy) *settings.DomainSettings {
return &settings.DomainSettings{
LoginNameIncludesDomain: current.UserLoginMustBeDomain,
RequireOrgDomainVerification: current.ValidateOrgDomains,
SmtpSenderAddressMatchesInstanceDomain: current.SMTPSenderAddressMatchesInstanceDomain,
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func legalAndSupportSettingsToPb(current *query.PrivacyPolicy) *settings.LegalAndSupportSettings {
return &settings.LegalAndSupportSettings{
TosLink: current.TOSLink,
PrivacyPolicyLink: current.PrivacyLink,
HelpLink: current.HelpLink,
SupportEmail: string(current.SupportEmail),
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func lockoutSettingsToPb(current *query.LockoutPolicy) *settings.LockoutSettings {
return &settings.LockoutSettings{
MaxPasswordAttempts: current.MaxPasswordAttempts,
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
}
}
func identityProvidersToPb(idps []*query.IDPLoginPolicyLink) []*settings.IdentityProvider {
providers := make([]*settings.IdentityProvider, len(idps))
for i, idp := range idps {
providers[i] = identityProviderToPb(idp)
}
return providers
}
func identityProviderToPb(idp *query.IDPLoginPolicyLink) *settings.IdentityProvider {
return &settings.IdentityProvider{
Id: idp.IDPID,
Name: domain.IDPName(idp.IDPName, idp.IDPType),
Type: idpTypeToPb(idp.IDPType),
}
}
func idpTypeToPb(idpType domain.IDPType) settings.IdentityProviderType {
switch idpType {
case domain.IDPTypeUnspecified:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
case domain.IDPTypeOIDC:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC
case domain.IDPTypeJWT:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT
case domain.IDPTypeOAuth:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH
case domain.IDPTypeLDAP:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP
case domain.IDPTypeAzureAD:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD
case domain.IDPTypeGitHub:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB
case domain.IDPTypeGitHubEnterprise:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES
case domain.IDPTypeGitLab:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB
case domain.IDPTypeGitLabSelfHosted:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED
case domain.IDPTypeGoogle:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE
default:
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
}
}

View File

@ -0,0 +1,461 @@
package settings
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
)
var ignoreMessageTypes = map[protoreflect.FullName]bool{
"google.protobuf.Duration": true,
}
// allFieldsSet recusively checks if all values in a message
// have a non-zero value.
func allFieldsSet(t testing.TB, msg protoreflect.Message) {
md := msg.Descriptor()
name := md.FullName()
if ignoreMessageTypes[name] {
return
}
fields := md.Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
if !msg.Has(fd) {
t.Errorf("not all fields set in %q, missing %q", name, fd.Name())
continue
}
if fd.Kind() == protoreflect.MessageKind {
allFieldsSet(t, msg.Get(fd).Message())
}
}
}
func Test_loginSettingsToPb(t *testing.T) {
arg := &query.LoginPolicy{
AllowUsernamePassword: true,
AllowRegister: true,
AllowExternalIDPs: true,
ForceMFA: true,
PasswordlessType: domain.PasswordlessTypeAllowed,
HidePasswordReset: true,
IgnoreUnknownUsernames: true,
AllowDomainDiscovery: true,
DisableLoginWithEmail: true,
DisableLoginWithPhone: true,
DefaultRedirectURI: "example.com",
PasswordCheckLifetime: time.Hour,
ExternalLoginCheckLifetime: time.Minute,
MFAInitSkipLifetime: time.Millisecond,
SecondFactorCheckLifetime: time.Microsecond,
MultiFactorCheckLifetime: time.Nanosecond,
SecondFactors: []domain.SecondFactorType{
domain.SecondFactorTypeOTP,
domain.SecondFactorTypeU2F,
},
MultiFactors: []domain.MultiFactorType{
domain.MultiFactorTypeU2FWithPIN,
},
IsDefault: true,
}
want := &settings.LoginSettings{
AllowUsernamePassword: true,
AllowRegister: true,
AllowExternalIdp: true,
ForceMfa: true,
PasskeysType: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
HidePasswordReset: true,
IgnoreUnknownUsernames: true,
AllowDomainDiscovery: true,
DisableLoginWithEmail: true,
DisableLoginWithPhone: true,
DefaultRedirectUri: "example.com",
PasswordCheckLifetime: durationpb.New(time.Hour),
ExternalLoginCheckLifetime: durationpb.New(time.Minute),
MfaInitSkipLifetime: durationpb.New(time.Millisecond),
SecondFactorCheckLifetime: durationpb.New(time.Microsecond),
MultiFactorCheckLifetime: durationpb.New(time.Nanosecond),
SecondFactors: []settings.SecondFactorType{
settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
},
MultiFactors: []settings.MultiFactorType{
settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
},
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := loginSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_isDefaultToResourceOwnerTypePb(t *testing.T) {
type args struct {
isDefault bool
}
tests := []struct {
args args
want settings.ResourceOwnerType
}{
{
args: args{false},
want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG,
},
{
args: args{true},
want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
got := isDefaultToResourceOwnerTypePb(tt.args.isDefault)
assert.Equal(t, tt.want, got)
})
}
}
func Test_passkeysTypeToPb(t *testing.T) {
type args struct {
passwordlessType domain.PasswordlessType
}
tests := []struct {
args args
want settings.PasskeysType
}{
{
args: args{domain.PasswordlessTypeNotAllowed},
want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
},
{
args: args{domain.PasswordlessTypeAllowed},
want: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
},
{
args: args{99},
want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
got := passkeysTypeToPb(tt.args.passwordlessType)
assert.Equal(t, tt.want, got)
})
}
}
func Test_secondFactorTypeToPb(t *testing.T) {
type args struct {
secondFactorType domain.SecondFactorType
}
tests := []struct {
args args
want settings.SecondFactorType
}{
{
args: args{domain.SecondFactorTypeOTP},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
},
{
args: args{domain.SecondFactorTypeU2F},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
},
{
args: args{domain.SecondFactorTypeUnspecified},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
},
{
args: args{99},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
got := secondFactorTypeToPb(tt.args.secondFactorType)
assert.Equal(t, tt.want, got)
})
}
}
func Test_multiFactorTypeToPb(t *testing.T) {
type args struct {
typ domain.MultiFactorType
}
tests := []struct {
args args
want settings.MultiFactorType
}{
{
args: args{domain.MultiFactorTypeU2FWithPIN},
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
},
{
args: args{domain.MultiFactorTypeUnspecified},
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
},
{
args: args{99},
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
got := multiFactorTypeToPb(tt.args.typ)
assert.Equal(t, tt.want, got)
})
}
}
func Test_passwordSettingsToPb(t *testing.T) {
arg := &query.PasswordComplexityPolicy{
MinLength: 12,
HasUppercase: true,
HasLowercase: true,
HasNumber: true,
HasSymbol: true,
IsDefault: true,
}
want := &settings.PasswordComplexitySettings{
MinLength: 12,
RequiresUppercase: true,
RequiresLowercase: true,
RequiresNumber: true,
RequiresSymbol: true,
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := passwordSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_brandingSettingsToPb(t *testing.T) {
arg := &query.LabelPolicy{
Light: query.Theme{
PrimaryColor: "red",
WarnColor: "white",
BackgroundColor: "blue",
FontColor: "orange",
LogoURL: "light-logo",
IconURL: "light-icon",
},
Dark: query.Theme{
PrimaryColor: "magenta",
WarnColor: "pink",
BackgroundColor: "black",
FontColor: "white",
LogoURL: "dark-logo",
IconURL: "dark-icon",
},
ResourceOwner: "me",
FontURL: "fonts",
WatermarkDisabled: true,
HideLoginNameSuffix: true,
IsDefault: true,
}
want := &settings.BrandingSettings{
LightTheme: &settings.Theme{
PrimaryColor: "red",
WarnColor: "white",
BackgroundColor: "blue",
FontColor: "orange",
LogoUrl: "http://example.com/me/light-logo",
IconUrl: "http://example.com/me/light-icon",
},
DarkTheme: &settings.Theme{
PrimaryColor: "magenta",
WarnColor: "pink",
BackgroundColor: "black",
FontColor: "white",
LogoUrl: "http://example.com/me/dark-logo",
IconUrl: "http://example.com/me/dark-icon",
},
FontUrl: "http://example.com/me/fonts",
DisableWatermark: true,
HideLoginNameSuffix: true,
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := brandingSettingsToPb(arg, "http://example.com")
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_domainSettingsToPb(t *testing.T) {
arg := &query.DomainPolicy{
UserLoginMustBeDomain: true,
ValidateOrgDomains: true,
SMTPSenderAddressMatchesInstanceDomain: true,
IsDefault: true,
}
want := &settings.DomainSettings{
LoginNameIncludesDomain: true,
RequireOrgDomainVerification: true,
SmtpSenderAddressMatchesInstanceDomain: true,
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := domainSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_legalSettingsToPb(t *testing.T) {
arg := &query.PrivacyPolicy{
TOSLink: "http://example.com/tos",
PrivacyLink: "http://example.com/pricacy",
HelpLink: "http://example.com/help",
SupportEmail: "support@zitadel.com",
IsDefault: true,
}
want := &settings.LegalAndSupportSettings{
TosLink: "http://example.com/tos",
PrivacyPolicyLink: "http://example.com/pricacy",
HelpLink: "http://example.com/help",
SupportEmail: "support@zitadel.com",
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := legalAndSupportSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_lockoutSettingsToPb(t *testing.T) {
arg := &query.LockoutPolicy{
MaxPasswordAttempts: 22,
IsDefault: true,
}
want := &settings.LockoutSettings{
MaxPasswordAttempts: 22,
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := lockoutSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
if !proto.Equal(got, want) {
t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want)
}
}
func Test_identityProvidersToPb(t *testing.T) {
arg := []*query.IDPLoginPolicyLink{
{
IDPID: "1",
IDPName: "foo",
IDPType: domain.IDPTypeOIDC,
},
{
IDPID: "2",
IDPName: "bar",
IDPType: domain.IDPTypeGitHub,
},
}
want := []*settings.IdentityProvider{
{
Id: "1",
Name: "foo",
Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
},
{
Id: "2",
Name: "bar",
Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
},
}
got := identityProvidersToPb(arg)
require.Len(t, got, len(got))
for i, v := range got {
allFieldsSet(t, v.ProtoReflect())
if !proto.Equal(v, want[i]) {
t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
}
}
}
func Test_idpTypeToPb(t *testing.T) {
type args struct {
idpType domain.IDPType
}
tests := []struct {
args args
want settings.IdentityProviderType
}{
{
args: args{domain.IDPTypeUnspecified},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
},
{
args: args{domain.IDPTypeOIDC},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
},
{
args: args{domain.IDPTypeJWT},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT,
},
{
args: args{domain.IDPTypeOAuth},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH,
},
{
args: args{domain.IDPTypeLDAP},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP,
},
{
args: args{domain.IDPTypeAzureAD},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD,
},
{
args: args{domain.IDPTypeGitHub},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
},
{
args: args{domain.IDPTypeGitHubEnterprise},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES,
},
{
args: args{domain.IDPTypeGitLab},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB,
},
{
args: args{domain.IDPTypeGitLabSelfHosted},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED,
},
{
args: args{domain.IDPTypeGoogle},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE,
},
{
args: args{99},
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
if got := idpTypeToPb(tt.args.idpType); !reflect.DeepEqual(got, tt.want) {
t.Errorf("idpTypeToPb() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -21,11 +21,7 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp
case *user.SetEmailRequest_ReturnCode: case *user.SetEmailRequest_ReturnCode:
email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg) email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
case *user.SetEmailRequest_IsVerified: case *user.SetEmailRequest_IsVerified:
if v.IsVerified {
email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail()) email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail())
} else {
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
}
case nil: case nil:
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg) email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
default: default:

View File

@ -0,0 +1,210 @@
//go:build integration
package user_test
import (
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
"google.golang.org/protobuf/types/known/timestamppb"
)
func createHumanUser(t *testing.T) *user.AddHumanUserResponse {
resp, err := Client.AddHumanUser(CTX, &user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Mickey",
LastName: "Mouse",
},
Email: &user.SetHumanEmail{
Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
})
require.NoError(t, err)
require.NotEmpty(t, resp.GetUserId())
return resp
}
func TestServer_SetEmail(t *testing.T) {
userID := createHumanUser(t).GetUserId()
tests := []struct {
name string
req *user.SetEmailRequest
want *user.SetEmailResponse
wantErr bool
}{
{
name: "default verfication",
req: &user.SetEmailRequest{
UserId: userID,
Email: "default-verifier@mouse.com",
},
want: &user.SetEmailResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "custom url template",
req: &user.SetEmailRequest{
UserId: userID,
Email: "custom-url@mouse.com",
Verification: &user.SetEmailRequest_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
},
},
},
want: &user.SetEmailResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "template error",
req: &user.SetEmailRequest{
UserId: userID,
Email: "custom-url@mouse.com",
Verification: &user.SetEmailRequest_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("{{"),
},
},
},
wantErr: true,
},
{
name: "return code",
req: &user.SetEmailRequest{
UserId: userID,
Email: "return-code@mouse.com",
Verification: &user.SetEmailRequest_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
want: &user.SetEmailResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
VerificationCode: gu.Ptr("xxx"),
},
},
{
name: "is verified true",
req: &user.SetEmailRequest{
UserId: userID,
Email: "verified-true@mouse.com",
Verification: &user.SetEmailRequest_IsVerified{
IsVerified: true,
},
},
want: &user.SetEmailResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "is verified false",
req: &user.SetEmailRequest{
UserId: userID,
Email: "verified-false@mouse.com",
Verification: &user.SetEmailRequest_IsVerified{
IsVerified: false,
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.SetEmail(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
if tt.want.GetVerificationCode() != "" {
assert.NotEmpty(t, got.GetVerificationCode())
}
})
}
}
func TestServer_VerifyEmail(t *testing.T) {
userResp := createHumanUser(t)
tests := []struct {
name string
req *user.VerifyEmailRequest
want *user.VerifyEmailResponse
wantErr bool
}{
{
name: "wrong code",
req: &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: "xxx",
},
wantErr: true,
},
{
name: "wrong user",
req: &user.VerifyEmailRequest{
UserId: "xxx",
VerificationCode: userResp.GetEmailCode(),
},
wantErr: true,
},
{
name: "verify user",
req: &user.VerifyEmailRequest{
UserId: userResp.GetUserId(),
VerificationCode: userResp.GetEmailCode(),
},
want: &user.VerifyEmailResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.VerifyEmail(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
) )
func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) {
@ -19,10 +19,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest
if err != nil { if err != nil {
return nil, err return nil, err
} }
orgID := req.GetOrganisation().GetOrgId() orgID := authz.GetCtxData(ctx).OrgID
if orgID == "" {
orgID = authz.GetCtxData(ctx).OrgID
}
err = s.command.AddHuman(ctx, orgID, human, false) err = s.command.AddHuman(ctx, orgID, human, false)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -0,0 +1,317 @@
//go:build integration
package user_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
"google.golang.org/protobuf/types/known/timestamppb"
)
var (
CTX context.Context
ErrCTX context.Context
Tester *integration.Tester
Client user.UserServiceClient
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, errCtx, cancel := integration.Contexts(time.Hour)
defer cancel()
Tester = integration.NewTester(ctx)
defer Tester.Done()
CTX, ErrCTX = Tester.WithSystemAuthorization(ctx, integration.OrgOwner), errCtx
Client = user.NewUserServiceClient(Tester.GRPCClientConn)
return m.Run()
}())
}
func TestServer_AddHumanUser(t *testing.T) {
type args struct {
ctx context.Context
req *user.AddHumanUserRequest
}
tests := []struct {
name string
args args
want *user.AddHumanUserResponse
wantErr bool
}{
{
name: "default verification",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Donald",
LastName: "Duck",
NickName: gu.Ptr("Dukkie"),
DisplayName: gu.Ptr("Donald Duck"),
PreferredLanguage: gu.Ptr("en"),
Gender: user.Gender_GENDER_DIVERSE.Enum(),
},
Email: &user.SetHumanEmail{},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
want: &user.AddHumanUserResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "return verification code",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Donald",
LastName: "Duck",
NickName: gu.Ptr("Dukkie"),
DisplayName: gu.Ptr("Donald Duck"),
PreferredLanguage: gu.Ptr("en"),
Gender: user.Gender_GENDER_DIVERSE.Enum(),
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
want: &user.AddHumanUserResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
EmailCode: gu.Ptr("something"),
},
},
{
name: "custom template",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Donald",
LastName: "Duck",
NickName: gu.Ptr("Dukkie"),
DisplayName: gu.Ptr("Donald Duck"),
PreferredLanguage: gu.Ptr("en"),
Gender: user.Gender_GENDER_DIVERSE.Enum(),
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
},
},
},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
want: &user.AddHumanUserResponse{
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "custom template error",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Donald",
LastName: "Duck",
NickName: gu.Ptr("Dukkie"),
DisplayName: gu.Ptr("Donald Duck"),
PreferredLanguage: gu.Ptr("en"),
Gender: user.Gender_GENDER_DIVERSE.Enum(),
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_SendCode{
SendCode: &user.SendEmailVerificationCode{
UrlTemplate: gu.Ptr("{{"),
},
},
},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
wantErr: true,
},
{
name: "missing REQUIRED profile",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Email: &user.SetHumanEmail{
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
wantErr: true,
},
{
name: "missing REQUIRED email",
args: args{
CTX,
&user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Donald",
LastName: "Duck",
NickName: gu.Ptr("Dukkie"),
DisplayName: gu.Ptr("Donald Duck"),
PreferredLanguage: gu.Ptr("en"),
Gender: user.Gender_GENDER_DIVERSE.Enum(),
},
Metadata: []*user.SetMetadataEntry{
{
Key: "somekey",
Value: []byte("somevalue"),
},
},
PasswordType: &user.AddHumanUserRequest_Password{
Password: &user.Password{
Password: "DifficultPW666!",
ChangeRequired: true,
},
},
},
},
wantErr: true,
},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userID := fmt.Sprint(time.Now().UnixNano() + int64(i))
tt.args.req.UserId = &userID
if email := tt.args.req.GetEmail(); email != nil {
email.Email = fmt.Sprintf("%s@me.now", userID)
}
if tt.want != nil {
tt.want.UserId = userID
}
got, err := Client.AddHumanUser(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.GetUserId(), got.GetUserId())
if tt.want.GetEmailCode() != "" {
assert.NotEmpty(t, got.GetEmailCode())
}
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@ -23,6 +23,7 @@ const (
XUserAgent = "x-user-agent" XUserAgent = "x-user-agent"
XGrpcWeb = "x-grpc-web" XGrpcWeb = "x-grpc-web"
XRequestedWith = "x-requested-with" XRequestedWith = "x-requested-with"
XRobotsTag = "x-robots-tag"
IfNoneMatch = "If-None-Match" IfNoneMatch = "If-None-Match"
LastModified = "Last-Modified" LastModified = "Last-Modified"
Etag = "Etag" Etag = "Etag"

View File

@ -1,15 +1,16 @@
package middleware package middleware
import ( import (
"math" "net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strings"
"time" "time"
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
http_utils "github.com/zitadel/zitadel/internal/api/http" http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/emitters/access" "github.com/zitadel/zitadel/internal/logstore/emitters/access"
@ -20,6 +21,7 @@ type AccessInterceptor struct {
svc *logstore.Service svc *logstore.Service
cookieHandler *http_utils.CookieHandler cookieHandler *http_utils.CookieHandler
limitConfig *AccessConfig limitConfig *AccessConfig
storeOnly bool
} }
type AccessConfig struct { type AccessConfig struct {
@ -27,14 +29,15 @@ type AccessConfig struct {
ExhaustedCookieMaxAge time.Duration ExhaustedCookieMaxAge time.Duration
} }
func NewAccessInterceptor(svc *logstore.Service, cookieConfig *AccessConfig) *AccessInterceptor { // NewAccessInterceptor intercepts all requests and stores them to the logstore.
// If storeOnly is false, it also checks if requests are exhausted.
// If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie
func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig, storeOnly bool) *AccessInterceptor {
return &AccessInterceptor{ return &AccessInterceptor{
svc: svc, svc: svc,
cookieHandler: http_utils.NewCookieHandler( cookieHandler: cookieHandler,
http_utils.WithUnsecure(),
http_utils.WithMaxAge(int(math.Floor(cookieConfig.ExhaustedCookieMaxAge.Seconds()))),
),
limitConfig: cookieConfig, limitConfig: cookieConfig,
storeOnly: storeOnly,
} }
} }
@ -43,34 +46,34 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler {
return next return next
} }
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
ctx := request.Context() ctx := request.Context()
var err error tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccess")
tracingCtx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0} wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0}
instance := authz.GetInstance(ctx) instance := authz.GetInstance(ctx)
limit := false
if !a.storeOnly {
remaining := a.svc.Limit(tracingCtx, instance.InstanceID()) remaining := a.svc.Limit(tracingCtx, instance.InstanceID())
limit := remaining != nil && *remaining == 0 limit = remaining != nil && *remaining == 0
a.cookieHandler.SetCookie(wrappedWriter, a.limitConfig.ExhaustedCookieKey, request.Host, strconv.FormatBool(limit))
if limit {
wrappedWriter.WriteHeader(http.StatusTooManyRequests)
wrappedWriter.ignoreWrites = true
} }
checkSpan.End()
if limit {
// Limit can only be true when storeOnly is false, so set the cookie and the response code
SetExhaustedCookie(a.cookieHandler, wrappedWriter, a.limitConfig, request)
http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests)
} else {
if !a.storeOnly {
// If not limited and not storeOnly, ensure the cookie is deleted
DeleteExhaustedCookie(a.cookieHandler, wrappedWriter, request, a.limitConfig)
}
// Always serve if not limited
next.ServeHTTP(wrappedWriter, request) next.ServeHTTP(wrappedWriter, request)
}
tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess")
defer writeSpan.End()
requestURL := request.RequestURI requestURL := request.RequestURI
unescapedURL, err := url.QueryUnescape(requestURL) unescapedURL, err := url.QueryUnescape(requestURL)
if err != nil { if err != nil {
logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url") logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url")
// err = nil is effective because of deferred tracing span end
err = nil
} }
a.svc.Handle(tracingCtx, &access.Record{ a.svc.Handle(tracingCtx, &access.Record{
LogDate: time.Now(), LogDate: time.Now(),
@ -87,6 +90,24 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler {
}) })
} }
func SetExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, cookieConfig *AccessConfig, request *http.Request) {
cookieValue := "true"
host := request.Header.Get(middleware.HTTP1Host)
domain := host
if strings.ContainsAny(host, ":") {
var err error
domain, _, err = net.SplitHostPort(host)
if err != nil {
logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host")
}
}
cookieHandler.SetCookie(writer, cookieConfig.ExhaustedCookieKey, domain, cookieValue)
}
func DeleteExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, request *http.Request, cookieConfig *AccessConfig) {
cookieHandler.DeleteCookie(writer, request, cookieConfig.ExhaustedCookieKey)
}
type statusRecorder struct { type statusRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int

View File

@ -62,7 +62,7 @@ func authorize(r *http.Request, verifier *authz.TokenVerifier, authConfig authz.
return nil, errors.New("auth header missing") return nil, errors.New("auth header missing")
} }
ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), verifier, authConfig, authOpt, r.RequestURI) ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,14 @@
package middleware
import (
"net/http"
http_utils "github.com/zitadel/zitadel/internal/api/http"
)
func RobotsTagHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(http_utils.XRobotsTag, "none")
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,24 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_RobotsTagInterceptor(t *testing.T) {
testHandler := func(w http.ResponseWriter, r *http.Request) {}
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := RobotsTagHandler(http.HandlerFunc(testHandler))
handler.ServeHTTP(recorder, req)
res := recorder.Result()
exp := res.Header.Get("X-Robots-Tag")
assert.Equal(t, "none", exp)
defer res.Body.Close()
}

View File

@ -0,0 +1,19 @@
package robots_txt
import (
"fmt"
"net/http"
)
const (
HandlerPrefix = "/robots.txt"
)
func Start() (http.Handler, error) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/text")
fmt.Fprintf(w, "User-agent: *\nDisallow: /\n")
})
return handler, nil
}

View File

@ -0,0 +1,28 @@
package robots_txt
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_RobotsTxt(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
recorder := httptest.NewRecorder()
handler, err := Start()
handler.ServeHTTP(recorder, req)
assert.Equal(t, nil, err)
res := recorder.Result()
body, err := io.ReadAll(res.Body)
assert.Equal(t, nil, err)
assert.Equal(t, 200, res.StatusCode)
assert.Equal(t, "User-agent: *\nDisallow: /\n", string(body))
defer res.Body.Close()
}

View File

@ -1,9 +1,11 @@
package console package console
import ( import (
"bytes"
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@ -24,6 +26,7 @@ import (
type Config struct { type Config struct {
ShortCache middleware.CacheConfig ShortCache middleware.CacheConfig
LongCache middleware.CacheConfig LongCache middleware.CacheConfig
InstanceManagementURL string
} }
type spaHandler struct { type spaHandler struct {
@ -106,7 +109,13 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
handler.Use(callDurationInterceptor, instanceHandler, security, accessInterceptor) handler.Use(callDurationInterceptor, instanceHandler, security, accessInterceptor)
handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
url := http_util.BuildOrigin(r.Host, externalSecure) url := http_util.BuildOrigin(r.Host, externalSecure)
environmentJSON, err := createEnvironmentJSON(url, issuer(r), authz.GetInstance(r.Context()).ConsoleClientID(), customerPortal) instance := authz.GetInstance(r.Context())
instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance)
if err != nil {
http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError)
return
}
environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError)
return return
@ -118,6 +127,18 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
return handler, nil return handler, nil
} }
func templateInstanceManagementURL(templateableCookieValue string, instance authz.Instance) (string, error) {
cookieValueTemplate, err := template.New("cookievalue").Parse(templateableCookieValue)
if err != nil {
return templateableCookieValue, err
}
cookieValue := new(bytes.Buffer)
if err = cookieValueTemplate.Execute(cookieValue, instance); err != nil {
return templateableCookieValue, err
}
return cookieValue.String(), nil
}
func csp() *middleware.CSP { func csp() *middleware.CSP {
csp := middleware.DefaultSCP csp := middleware.DefaultSCP
csp.StyleSrc = csp.StyleSrc.AddInline() csp.StyleSrc = csp.StyleSrc.AddInline()
@ -127,17 +148,19 @@ func csp() *middleware.CSP {
return &csp return &csp
} }
func createEnvironmentJSON(api, issuer, clientID, customerPortal string) ([]byte, error) { func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl string) ([]byte, error) {
environment := struct { environment := struct {
API string `json:"api,omitempty"` API string `json:"api,omitempty"`
Issuer string `json:"issuer,omitempty"` Issuer string `json:"issuer,omitempty"`
ClientID string `json:"clientid,omitempty"` ClientID string `json:"clientid,omitempty"`
CustomerPortal string `json:"customer_portal,omitempty"` CustomerPortal string `json:"customer_portal,omitempty"`
InstanceManagementURL string `json:"instance_management_url,omitempty"`
}{ }{
API: api, API: api,
Issuer: issuer, Issuer: issuer,
ClientID: clientID, ClientID: clientID,
CustomerPortal: customerPortal, CustomerPortal: customerPortal,
InstanceManagementURL: instanceMgmtUrl,
} }
return json.Marshal(environment) return json.Marshal(environment)
} }

View File

@ -23,6 +23,7 @@
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<meta name="description" content="{{ .Description }}"/> <meta name="description" content="{{ .Description }}"/>
<meta name="robots" content="none" />
<script src="{{ resourceUrl "scripts/theme.js" }}"></script> <script src="{{ resourceUrl "scripts/theme.js" }}"></script>
</head> </head>

View File

@ -20,6 +20,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
proj_repo "github.com/zitadel/zitadel/internal/repository/project" proj_repo "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/quota" "github.com/zitadel/zitadel/internal/repository/quota"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_repo "github.com/zitadel/zitadel/internal/repository/user"
usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant" usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant"
"github.com/zitadel/zitadel/internal/static" "github.com/zitadel/zitadel/internal/static"
@ -29,7 +30,7 @@ import (
type Commands struct { type Commands struct {
httpClient *http.Client httpClient *http.Client
checkPermission permissionCheck checkPermission domain.PermissionCheck
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
@ -50,6 +51,8 @@ type Commands struct {
domainVerificationAlg crypto.EncryptionAlgorithm domainVerificationAlg crypto.EncryptionAlgorithm
domainVerificationGenerator crypto.Generator domainVerificationGenerator crypto.Generator
domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error
sessionTokenCreator func(sessionID string) (id string, token string, err error)
sessionTokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
multifactors domain.MultifactorConfigs multifactors domain.MultifactorConfigs
webauthnConfig *webauthn_helper.Config webauthnConfig *webauthn_helper.Config
@ -71,24 +74,21 @@ func StartCommands(
externalDomain string, externalDomain string,
externalSecure bool, externalSecure bool,
externalPort uint16, externalPort uint16,
idpConfigEncryption, idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm,
otpEncryption,
smtpEncryption,
smsEncryption,
userEncryption,
domainVerificationEncryption,
oidcEncryption,
samlEncryption crypto.EncryptionAlgorithm,
httpClient *http.Client, httpClient *http.Client,
membershipsResolver authz.MembershipsResolver, permissionCheck domain.PermissionCheck,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
) (repo *Commands, err error) { ) (repo *Commands, err error) {
if externalDomain == "" { if externalDomain == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
} }
idGenerator := id.SonyFlakeGenerator()
// reuse the oidcEncryption to be able to handle both tokens in the interceptor later on
sessionAlg := oidcEncryption
repo = &Commands{ repo = &Commands{
eventstore: es, eventstore: es,
static: staticStore, static: staticStore,
idGenerator: id.SonyFlakeGenerator(), idGenerator: idGenerator,
zitadelRoles: zitadelRoles, zitadelRoles: zitadelRoles,
externalDomain: externalDomain, externalDomain: externalDomain,
externalSecure: externalSecure, externalSecure: externalSecure,
@ -107,10 +107,10 @@ func StartCommands(
certificateAlgorithm: samlEncryption, certificateAlgorithm: samlEncryption,
webauthnConfig: webAuthN, webauthnConfig: webAuthN,
httpClient: httpClient, httpClient: httpClient,
checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { checkPermission: permissionCheck,
return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf)
},
newEmailCode: newEmailCode, newEmailCode: newEmailCode,
sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
sessionTokenVerifier: sessionTokenVerifier,
} }
instance_repo.RegisterEventMappers(repo.eventstore) instance_repo.RegisterEventMappers(repo.eventstore)
@ -121,6 +121,7 @@ func StartCommands(
keypair.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore)
action.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore)
quota.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore)
session.RegisterEventMappers(repo.eventstore)
repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost)
repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize)

View File

@ -10,6 +10,7 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository"
@ -19,6 +20,7 @@ import (
key_repo "github.com/zitadel/zitadel/internal/repository/keypair" key_repo "github.com/zitadel/zitadel/internal/repository/keypair"
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
proj_repo "github.com/zitadel/zitadel/internal/repository/project" proj_repo "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_repo "github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/repository/usergrant" "github.com/zitadel/zitadel/internal/repository/usergrant"
) )
@ -38,6 +40,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore {
usergrant.RegisterEventMappers(es) usergrant.RegisterEventMappers(es)
key_repo.RegisterEventMappers(es) key_repo.RegisterEventMappers(es)
action_repo.RegisterEventMappers(es) action_repo.RegisterEventMappers(es)
session.RegisterEventMappers(es)
return es return es
} }
@ -125,6 +128,11 @@ func expectFilter(events ...*repository.Event) expect {
m.ExpectFilterEvents(events...) m.ExpectFilterEvents(events...)
} }
} }
func expectFilterError(err error) expect {
return func(m *mock.MockRepository) {
m.ExpectFilterEventsError(err)
}
}
func expectFilterOrgDomainNotFound() expect { func expectFilterOrgDomainNotFound() expect {
return func(m *mock.MockRepository) { return func(m *mock.MockRepository) {
@ -250,14 +258,14 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func newMockPermissionCheckAllowed() permissionCheck { func newMockPermissionCheckAllowed() domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return nil return nil
} }
} }
func newMockPermissionCheckNotAllowed() permissionCheck { func newMockPermissionCheckNotAllowed() domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")
} }
} }

View File

@ -1,11 +0,0 @@
package command
import (
"context"
)
type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error)
const (
permissionUserWrite = "user.write"
)

225
internal/command/session.go Normal file
View File

@ -0,0 +1,225 @@
package command
import (
"context"
"encoding/base64"
"fmt"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
type SessionCheck func(ctx context.Context, cmd *SessionChecks) error
type SessionChecks struct {
checks []SessionCheck
sessionWriteModel *SessionWriteModel
passwordWriteModel *HumanPasswordWriteModel
eventstore *eventstore.Eventstore
userPasswordAlg crypto.HashAlgorithm
createToken func(sessionID string) (id string, token string, err error)
now func() time.Time
}
func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks {
return &SessionChecks{
checks: checks,
sessionWriteModel: session,
eventstore: c.eventstore,
userPasswordAlg: c.userPasswordAlg,
createToken: c.sessionTokenCreator,
now: time.Now,
}
}
// CheckUser defines a user check to be executed for a session update
func CheckUser(id string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id {
return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible")
}
return cmd.sessionWriteModel.UserChecked(ctx, id, cmd.now())
}
}
// CheckPassword defines a password check to be executed for a session update
func CheckPassword(password string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
if cmd.sessionWriteModel.UserID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing")
}
cmd.passwordWriteModel = NewHumanPasswordWriteModel(cmd.sessionWriteModel.UserID, "")
err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.passwordWriteModel)
if err != nil {
return err
}
if cmd.passwordWriteModel.UserState == domain.UserStateUnspecified || cmd.passwordWriteModel.UserState == domain.UserStateDeleted {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.User.NotFound")
}
if cmd.passwordWriteModel.Secret == nil {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-WEf3t", "Errors.User.Password.NotSet")
}
ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
err = crypto.CompareHash(cmd.passwordWriteModel.Secret, []byte(password), cmd.userPasswordAlg)
spanPasswordComparison.EndWithError(err)
if err != nil {
//TODO: maybe we want to reset the session in the future https://github.com/zitadel/zitadel/issues/5807
return caos_errs.ThrowInvalidArgument(err, "COMMAND-SAF3g", "Errors.User.Password.Invalid")
}
cmd.sessionWriteModel.PasswordChecked(ctx, cmd.now())
return nil
}
}
// Check will execute the checks specified and return an error on the first occurrence
func (s *SessionChecks) Check(ctx context.Context) error {
for _, check := range s.checks {
if err := check(ctx, s); err != nil {
return err
}
}
return nil
}
func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) {
if len(s.sessionWriteModel.commands) == 0 {
return "", nil, nil
}
tokenID, token, err := s.createToken(s.sessionWriteModel.AggregateID)
if err != nil {
return "", nil, err
}
s.sessionWriteModel.SetToken(ctx, tokenID)
return token, s.sessionWriteModel.commands, nil
}
func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionID, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
cmd.sessionWriteModel.Start(ctx)
return c.updateSession(ctx, cmd, metadata)
}
func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, err
}
if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
return c.updateSession(ctx, cmd, metadata)
}
func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken string) (*domain.ObjectDetails, error) {
sessionWriteModel := NewSessionWriteModel(sessionID, "")
if err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil {
return nil, err
}
if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionDelete); err != nil {
return nil, err
}
if sessionWriteModel.State != domain.SessionStateActive {
return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil
}
terminate := session.NewTerminateEvent(ctx, &session.NewAggregate(sessionWriteModel.AggregateID, sessionWriteModel.ResourceOwner).Aggregate)
pushedEvents, err := c.eventstore.Push(ctx, terminate)
if err != nil {
return nil, err
}
err = AppendAndReduce(sessionWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil
}
// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes)
func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) {
if checks.sessionWriteModel.State == domain.SessionStateTerminated {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated")
}
if err := checks.Check(ctx); err != nil {
// TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807
return nil, err
}
checks.sessionWriteModel.ChangeMetadata(ctx, metadata)
sessionToken, cmds, err := checks.commands(ctx)
if err != nil {
return nil, err
}
if len(cmds) == 0 {
return sessionWriteModelToSessionChanged(checks.sessionWriteModel), nil
}
pushedEvents, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return nil, err
}
err = AppendAndReduce(checks.sessionWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
changed := sessionWriteModelToSessionChanged(checks.sessionWriteModel)
changed.NewToken = sessionToken
return changed, nil
}
// sessionPermission will check that the provided sessionToken is correct or
// if empty, check that the caller is granted the necessary permission
func (c *Commands) sessionPermission(ctx context.Context, sessionWriteModel *SessionWriteModel, sessionToken, permission string) (err error) {
if sessionToken == "" {
return c.checkPermission(ctx, permission, authz.GetCtxData(ctx).OrgID, sessionWriteModel.AggregateID)
}
return c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID)
}
func sessionTokenCreator(idGenerator id.Generator, sessionAlg crypto.EncryptionAlgorithm) func(sessionID string) (id string, token string, err error) {
return func(sessionID string) (id string, token string, err error) {
id, err = idGenerator.Next()
if err != nil {
return "", "", err
}
encrypted, err := sessionAlg.Encrypt([]byte(fmt.Sprintf(authz.SessionTokenFormat, sessionID, id)))
if err != nil {
return "", "", err
}
return id, base64.RawURLEncoding.EncodeToString(encrypted), nil
}
}
type SessionChanged struct {
*domain.ObjectDetails
ID string
NewToken string
}
func sessionWriteModelToSessionChanged(wm *SessionWriteModel) *SessionChanged {
return &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
Sequence: wm.ProcessedSequence,
EventDate: wm.ChangeDate,
ResourceOwner: wm.ResourceOwner,
},
ID: wm.AggregateID,
}
}

View File

@ -0,0 +1,139 @@
package command
import (
"bytes"
"context"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
)
type SessionWriteModel struct {
eventstore.WriteModel
TokenID string
UserID string
UserCheckedAt time.Time
PasswordCheckedAt time.Time
Metadata map[string][]byte
State domain.SessionState
commands []eventstore.Command
aggregate *eventstore.Aggregate
}
func NewSessionWriteModel(sessionID string, resourceOwner string) *SessionWriteModel {
return &SessionWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: sessionID,
ResourceOwner: resourceOwner,
},
Metadata: make(map[string][]byte),
aggregate: &session.NewAggregate(sessionID, resourceOwner).Aggregate,
}
}
func (wm *SessionWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *session.AddedEvent:
wm.reduceAdded(e)
case *session.UserCheckedEvent:
wm.reduceUserChecked(e)
case *session.PasswordCheckedEvent:
wm.reducePasswordChecked(e)
case *session.TokenSetEvent:
wm.reduceTokenSet(e)
case *session.TerminateEvent:
wm.reduceTerminate()
}
}
return wm.WriteModel.Reduce()
}
func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(session.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
session.AddedType,
session.UserCheckedType,
session.PasswordCheckedType,
session.TokenSetType,
session.MetadataSetType,
session.TerminateType,
).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) {
wm.State = domain.SessionStateActive
}
func (wm *SessionWriteModel) reduceUserChecked(e *session.UserCheckedEvent) {
wm.UserID = e.UserID
wm.UserCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEvent) {
wm.PasswordCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID
}
func (wm *SessionWriteModel) reduceTerminate() {
wm.State = domain.SessionStateTerminated
}
func (wm *SessionWriteModel) Start(ctx context.Context) {
wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate))
}
func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error {
wm.commands = append(wm.commands, session.NewUserCheckedEvent(ctx, wm.aggregate, userID, checkedAt))
// set the userID so other checks can use it
wm.UserID = userID
return nil
}
func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time.Time) {
wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt))
}
func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) {
wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID))
}
func (wm *SessionWriteModel) ChangeMetadata(ctx context.Context, metadata map[string][]byte) {
var changed bool
for key, value := range metadata {
currentValue, exists := wm.Metadata[key]
if len(value) != 0 {
// if a value is provided, and it's not equal, change it
if !bytes.Equal(currentValue, value) {
wm.Metadata[key] = value
changed = true
}
} else {
// if there's no / an empty value, we only need to remove it on existing entries
if exists {
delete(wm.Metadata, key)
changed = true
}
}
}
if changed {
wm.commands = append(wm.commands, session.NewMetadataSetEvent(ctx, wm.aggregate, wm.Metadata))
}
}

View File

@ -0,0 +1,547 @@
package command
import (
"context"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommands_CreateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
tokenCreator func(sessionID string) (string, string, error)
}
type args struct {
ctx context.Context
checks []SessionCheck
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"id generator fails",
fields{
idGenerator: mock.NewIDGeneratorExpectError(t, caos_errs.ThrowInternal(nil, "id", "generator failed")),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "generator failed"),
},
},
{
"eventstore failed",
fields{
idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"),
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"empty session",
fields{
idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"),
eventstore: eventstoreExpect(t,
expectFilter(),
expectPush(
eventPusherToEvents(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate),
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID",
),
),
),
),
tokenCreator: func(sessionID string) (string, string, error) {
return "tokenID",
"token",
nil
},
},
args{
ctx: authz.NewMockContext("", "org1", ""),
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"},
ID: "sessionID",
NewToken: "token",
},
},
},
// the rest is tested in the Test_updateSession
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
sessionTokenCreator: tt.fields.tokenCreator,
}
got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_UpdateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
}
type args struct {
ctx context.Context
sessionID string
sessionToken string
checks []SessionCheck
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"eventstore failed",
fields{
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"invalid session token",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "invalid",
},
res{
err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
},
{
"no change",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "",
},
},
},
// the rest is tested in the Test_updateSession
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
sessionTokenVerifier: tt.fields.tokenVerifier,
}
got, err := c.UpdateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_updateSession(t *testing.T) {
testNow := time.Now()
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
ctx context.Context
checks *SessionChecks
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"terminated",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated},
},
},
res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated"),
},
},
{
"check failed",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
func(ctx context.Context, cmd *SessionChecks) error {
return caos_errs.ThrowInternal(nil, "id", "check failed")
},
},
},
},
res{
err: caos_errs.ThrowInternal(nil, "id", "check failed"),
},
},
{
"no change",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{},
},
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "",
},
},
},
{
"set user, password, metadata and token",
fields{
eventstore: eventstoreExpect(t,
expectPush(
eventPusherToEvents(
session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"userID", testNow),
session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
testNow),
session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
map[string][]byte{"key": []byte("value")}),
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
CheckUser("userID"),
CheckPassword("password"),
},
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "", "", "", "", language.English, domain.GenderUnspecified, "", false),
),
eventFromEventPusher(
user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeHash,
Algorithm: "hash",
KeyID: "",
Crypted: []byte("password"),
}, false, ""),
),
),
),
createToken: func(sessionID string) (string, string, error) {
return "tokenID",
"token",
nil
},
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
now: func() time.Time {
return testNow
},
},
metadata: map[string][]byte{
"key": []byte("value"),
},
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "token",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := c.updateSession(tt.args.ctx, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_TerminateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
}
type args struct {
ctx context.Context
sessionID string
sessionToken string
}
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"eventstore failed",
fields{
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"invalid session token",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "invalid",
},
res{
err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
},
{
"not active",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
eventFromEventPusher(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"push failed",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
expectPushFailed(
caos_errs.ThrowInternal(nil, "id", "pushed failed"),
eventPusherToEvents(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
err: caos_errs.ThrowInternal(nil, "id", "pushed failed"),
},
},
{
"terminate",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
expectPush(
eventPusherToEvents(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
sessionTokenVerifier: tt.fields.tokenVerifier,
}
got, err := c.TerminateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors" caos_errs "github.com/zitadel/zitadel/internal/errors"
@ -42,7 +43,7 @@ func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resource
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil { if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err return nil, err
} }
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
@ -70,9 +71,11 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, res
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil { if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err return nil, err
} }
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err return nil, err
} }

View File

@ -24,7 +24,7 @@ import (
func TestCommands_ChangeUserEmail(t *testing.T) { func TestCommands_ChangeUserEmail(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -174,7 +174,7 @@ func TestCommands_ChangeUserEmail(t *testing.T) {
func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -300,7 +300,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { func TestCommands_ChangeUserEmailReturnCode(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -410,7 +410,7 @@ func TestCommands_ChangeUserEmailReturnCode(t *testing.T) {
func TestCommands_ChangeUserEmailVerified(t *testing.T) { func TestCommands_ChangeUserEmailVerified(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -569,7 +569,7 @@ func TestCommands_ChangeUserEmailVerified(t *testing.T) {
func TestCommands_changeUserEmailWithGenerator(t *testing.T) { func TestCommands_changeUserEmailWithGenerator(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string

View File

@ -2,13 +2,14 @@ package database
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
) )
type StringArray []string type StringArray []string
// Scan implements the `database/sql.Scanner` interface. // Scan implements the [database/sql.Scanner] interface.
func (s *StringArray) Scan(src any) error { func (s *StringArray) Scan(src any) error {
array := new(pgtype.TextArray) array := new(pgtype.TextArray)
if err := array.Scan(src); err != nil { if err := array.Scan(src); err != nil {
@ -20,7 +21,7 @@ func (s *StringArray) Scan(src any) error {
return nil return nil
} }
// Value implements the `database/sql/driver.Valuer` interface. // Value implements the [database/sql/driver.Valuer] interface.
func (s StringArray) Value() (driver.Value, error) { func (s StringArray) Value() (driver.Value, error) {
if len(s) == 0 { if len(s) == 0 {
return nil, nil return nil, nil
@ -40,7 +41,7 @@ type enumField interface {
type EnumArray[F enumField] []F type EnumArray[F enumField] []F
// Scan implements the `database/sql.Scanner` interface. // Scan implements the [database/sql.Scanner] interface.
func (s *EnumArray[F]) Scan(src any) error { func (s *EnumArray[F]) Scan(src any) error {
array := new(pgtype.Int2Array) array := new(pgtype.Int2Array)
if err := array.Scan(src); err != nil { if err := array.Scan(src); err != nil {
@ -57,7 +58,7 @@ func (s *EnumArray[F]) Scan(src any) error {
return nil return nil
} }
// Value implements the `database/sql/driver.Valuer` interface. // Value implements the [database/sql/driver.Valuer] interface.
func (s EnumArray[F]) Value() (driver.Value, error) { func (s EnumArray[F]) Value() (driver.Value, error) {
if len(s) == 0 { if len(s) == 0 {
return nil, nil return nil, nil
@ -70,3 +71,25 @@ func (s EnumArray[F]) Value() (driver.Value, error) {
return array.Value() return array.Value()
} }
type Map[V any] map[string]V
// Scan implements the [database/sql.Scanner] interface.
func (m *Map[V]) Scan(src any) error {
bytea := new(pgtype.Bytea)
if err := bytea.Scan(src); err != nil {
return err
}
if len(bytea.Bytes) == 0 {
return nil
}
return json.Unmarshal(bytea.Bytes, &m)
}
// Value implements the [database/sql/driver.Valuer] interface.
func (m Map[V]) Value() (driver.Value, error) {
if len(m) == 0 {
return nil, nil
}
return json.Marshal(m)
}

View File

@ -0,0 +1,119 @@
package database
import (
"database/sql/driver"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMap_Scan(t *testing.T) {
type args struct {
src any
}
type res[V any] struct {
want Map[V]
err bool
}
type testCase[V any] struct {
name string
m Map[V]
args args
res[V]
}
tests := []testCase[string]{
{
"null",
Map[string]{},
args{src: "invalid"},
res[string]{
want: Map[string]{},
err: true,
},
},
{
"null",
Map[string]{},
args{src: nil},
res[string]{
want: Map[string]{},
},
},
{
"empty",
Map[string]{},
args{src: []byte(`{}`)},
res[string]{
want: Map[string]{},
},
},
{
"set",
Map[string]{},
args{src: []byte(`{"key": "value"}`)},
res[string]{
want: Map[string]{
"key": "value",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.m.Scan(tt.args.src); (err != nil) != tt.res.err {
t.Errorf("Scan() error = %v, wantErr %v", err, tt.res.err)
}
assert.Equal(t, tt.res.want, tt.m)
})
}
}
func TestMap_Value(t *testing.T) {
type res struct {
want driver.Value
err bool
}
type testCase[V any] struct {
name string
m Map[V]
res res
}
tests := []testCase[string]{
{
"nil",
nil,
res{
want: nil,
},
},
{
"empty",
Map[string]{},
res{
want: nil,
},
},
{
"set",
Map[string]{
"key": "value",
},
res{
want: driver.Value([]byte(`{"key":"value"}`)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.m.Value()
if tt.res.err {
assert.Error(t, err)
}
if !tt.res.err {
require.NoError(t, err)
assert.Equalf(t, tt.res.want, got, "Value()")
}
})
}
}

View File

@ -1,5 +1,7 @@
package domain package domain
import "github.com/zitadel/logging"
type IDPState int32 type IDPState int32
const ( const (
@ -56,3 +58,36 @@ func (t IDPType) GetCSSClass() string {
return "" return ""
} }
} }
func IDPName(name string, idpType IDPType) string {
if name != "" {
return name
}
return idpType.DisplayName()
}
// DisplayName returns the name or a default
// to be used when always a name must be displayed (e.g. login)
func (t IDPType) DisplayName() string {
switch t {
case IDPTypeGitHub:
return "GitHub"
case IDPTypeGitLab:
return "GitLab"
case IDPTypeGoogle:
return "Google"
case IDPTypeUnspecified,
IDPTypeOIDC,
IDPTypeJWT,
IDPTypeOAuth,
IDPTypeLDAP,
IDPTypeAzureAD,
IDPTypeGitHubEnterprise,
IDPTypeGitLabSelfHosted:
fallthrough
default:
// we should never get here, so log it
logging.Errorf("name of provider (type %d) is empty", t)
return ""
}
}

View File

@ -1,5 +1,7 @@
package domain package domain
import "context"
type Permissions struct { type Permissions struct {
Permissions []string Permissions []string
} }
@ -21,3 +23,12 @@ func (p *Permissions) appendPermission(ctxID, permission string) {
} }
p.Permissions = append(p.Permissions, permission) p.Permissions = append(p.Permissions, permission)
} }
type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error)
const (
PermissionUserWrite = "user.write"
PermissionSessionRead = "session.read"
PermissionSessionWrite = "session.write"
PermissionSessionDelete = "session.delete"
)

View File

@ -4,8 +4,6 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/eventstore/v1/models"
) )
@ -70,30 +68,7 @@ func (p IDPProvider) IsValid() bool {
// DisplayName returns the name or a default // DisplayName returns the name or a default
// to be used when always a name must be displayed (e.g. login) // to be used when always a name must be displayed (e.g. login)
func (p IDPProvider) DisplayName() string { func (p IDPProvider) DisplayName() string {
if p.Name != "" { return IDPName(p.Name, p.IDPType)
return p.Name
}
switch p.IDPType {
case IDPTypeGitHub:
return "GitHub"
case IDPTypeGitLab:
return "GitLab"
case IDPTypeGoogle:
return "Google"
case IDPTypeUnspecified,
IDPTypeOIDC,
IDPTypeJWT,
IDPTypeOAuth,
IDPTypeLDAP,
IDPTypeAzureAD,
IDPTypeGitHubEnterprise,
IDPTypeGitLabSelfHosted:
fallthrough
default:
// we should never get here, so log it
logging.Errorf("name of provider (type %d) is empty - id: %s", p.IDPType, p.IDPConfigID)
return ""
}
} }
type PasswordlessType int32 type PasswordlessType int32

View File

@ -0,0 +1,9 @@
package domain
type SessionState int32
const (
SessionStateUnspecified SessionState = iota
SessionStateActive
SessionStateTerminated
)

View File

@ -25,3 +25,9 @@ func ExpectID(t *testing.T, id string) *MockGenerator {
m.EXPECT().Next().Return(id, nil) m.EXPECT().Next().Return(id, nil)
return m return m
} }
func NewIDGeneratorExpectError(t *testing.T, err error) *MockGenerator {
m := NewMockGenerator(gomock.NewController(t))
m.EXPECT().Next().Return("", err)
return m
}

Some files were not shown because too many files have changed in this diff Show More