diff --git a/apps/docs/content/apis/openidoauth/endpoints.mdx b/apps/docs/content/apis/openidoauth/endpoints.mdx
index eb63092d0..bf9be6fbd 100644
--- a/apps/docs/content/apis/openidoauth/endpoints.mdx
+++ b/apps/docs/content/apis/openidoauth/endpoints.mdx
@@ -658,6 +658,36 @@ You could also combine caching with a risk-based on-demand refresh when a critic
Without caching you will call this endpoint on each request.
This might result in being rate limited for a large number of requests that come from the same backend.
+## registration_endpoint
+
+`${CUSTOM_DOMAIN}/oauth/v2/register`
+
+The registration_endpoint implements [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591).
+It lets clients register themselves as OIDC applications at runtime, which is required for example by [Model Context Protocol (MCP)](/guides/integrate/dynamic-client-registration) clients.
+
+
+The endpoint is disabled by default. It is only served and advertised as `registration_endpoint` in the [discovery document](#openid-connect-10-discovery)
+when it is [enabled](/guides/integrate/dynamic-client-registration#enable-dynamic-client-registration) in the instance's security settings.
+By default it additionally requires an access token whose user holds the `project.app.register_dynamic` permission.
+
+
+A successful registration returns `HTTP 201` with the `client_id` (and, for confidential clients, the `client_secret`) that can immediately be used for the authorization code flow.
+
+```bash
+curl --request POST \
+ --url ${CUSTOM_DOMAIN}/oauth/v2/register \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "client_name": "My MCP Client",
+ "redirect_uris": ["https://client.example.com/callback"],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none"
+ }'
+```
+
+See the [Dynamic Client Registration guide](/guides/integrate/dynamic-client-registration) for the supported metadata, the available registration modes and the current limitations.
+
## OAuth 2.0 metadata
**ZITADEL** does not yet provide a OAuth 2.0 Metadata endpoint but instead provides a [OpenID Connect Discovery Endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html).
diff --git a/apps/docs/content/guides/integrate/dynamic-client-registration.mdx b/apps/docs/content/guides/integrate/dynamic-client-registration.mdx
new file mode 100644
index 000000000..5a2be8e20
--- /dev/null
+++ b/apps/docs/content/guides/integrate/dynamic-client-registration.mdx
@@ -0,0 +1,128 @@
+---
+title: OAuth 2.0 Dynamic Client Registration
+description: "Let clients register themselves as OIDC applications at runtime using RFC 7591, for example for Model Context Protocol (MCP) clients."
+sidebar_label: Dynamic Client Registration
+---
+
+ZITADEL implements [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591).
+It exposes a [`registration_endpoint`](/apis/openidoauth/endpoints#registration-endpoint) that lets clients register
+themselves as OIDC applications at runtime, instead of being created upfront in the Management Console or through the management API.
+
+This is required by clients that self-register before any user context exists, most notably
+[Model Context Protocol (MCP)](https://modelcontextprotocol.io) clients such as Claude Desktop, claude.ai and the MCP SDKs.
+
+
+Dynamic client registration is disabled by default. Only enable it if you understand the trade-offs described in
+[Registration modes](#registration-modes) below.
+
+
+## Enable dynamic client registration
+
+The endpoint is only served and advertised as `registration_endpoint` in the
+[discovery document](/apis/openidoauth/endpoints#openid-connect-10-discovery) when it is enabled in the instance's
+security settings.
+
+Enable it through the [settings API](/apis/resources/settings_service_v2/settings-service-set-security-settings):
+
+```bash
+curl --request PUT \
+ --url ${CUSTOM_DOMAIN}/v2/settings/security \
+ --header 'Authorization: Bearer ' \
+ --header 'Content-Type: application/json' \
+ --data '{ "dynamicClientRegistration": { "enabled": true } }'
+```
+
+## Registration modes
+
+The flow that MCP clients follow expects an open, unauthenticated registration endpoint, because the client registers
+before any user has authenticated. As this is a sensitive capability, ZITADEL supports two modes, selected through the
+same security settings. Both require `enabled` above.
+
+| Mode | Setting | Authorization | Organization of the registered client |
+| --- | --- | --- | --- |
+| Token (default) | `dynamicClientRegistration.allowUnauthenticated = false` | An access token must be sent as `Authorization: Bearer` header, and its user must hold the `project.app.register_dynamic` permission in the token's organization. | The token's organization. |
+| Open registration | `dynamicClientRegistration.allowUnauthenticated = true` | None. Required for the MCP flow. | The instance's default organization. |
+
+### Who may register in token mode
+
+The `project.app.register_dynamic` permission is granted by default to `ORG_OWNER`, `IAM_OWNER` and `IAM_ORG_MANAGER`.
+
+For service users that should do nothing but register clients, assign the dedicated
+[`ORG_DYNAMIC_CLIENT_REGISTRAR`](/guides/manage/console/administrators) role instead. It carries this single permission,
+so such a user can self-register clients without gaining write access to your existing applications.
+
+Whenever an `Authorization: Bearer` header is present it is treated as a registration token, even with open registration
+enabled. An invalid or expired token is rejected with `401 Unauthorized` and a valid token without the permission with
+`403 Forbidden`; neither falls back to anonymous registration.
+
+In both modes the registered clients are stored in a dedicated, auto-provisioned project named `ZITADEL DCR` in the
+respective organization, so they do not interfere with your other projects. When open registration is enabled, protect
+the endpoint with the existing instance rate limiting and only allow the redirect URI schemes you trust.
+
+## Register a client
+
+Send the [client metadata](#supported-metadata) as JSON to the `registration_endpoint`:
+
+```bash
+curl --request POST \
+ --url ${CUSTOM_DOMAIN}/oauth/v2/register \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "client_name": "My MCP Client",
+ "redirect_uris": ["https://client.example.com/callback"],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none"
+ }'
+```
+
+A successful registration returns `HTTP 201` with the client information response:
+
+```json
+{
+ "client_id": "340396026519785524",
+ "client_id_issued_at": 1718000000,
+ "client_name": "My MCP Client",
+ "redirect_uris": ["https://client.example.com/callback"],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none"
+}
+```
+
+For confidential clients (`token_endpoint_auth_method` of `client_secret_basic` or `client_secret_post`) the response
+also contains a `client_secret` and `client_secret_expires_at` (`0`, secrets do not expire).
+
+The returned `client_id` can be used immediately to start an
+[authorization code flow](/guides/integrate/login/oidc/login-users). Public clients (`token_endpoint_auth_method` of
+`none`) must use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636).
+
+## Supported metadata
+
+| Member | Notes |
+| --- | --- |
+| `redirect_uris` | Required. Validated against the same rules as applications created in the Management Console. |
+| `token_endpoint_auth_method` | `client_secret_basic`, `client_secret_post` or `none`. Defaults to `none` for native applications and `client_secret_basic` for web applications. |
+| `grant_types` | Defaults to `["authorization_code"]`. `refresh_token`, `implicit`, `urn:ietf:params:oauth:grant-type:device_code` and `urn:ietf:params:oauth:grant-type:token-exchange` are supported. |
+| `response_types` | Defaults to `["code"]`. `id_token` and `id_token token` are supported. |
+| `application_type` | `web` or `native`. When omitted, defaults to `web`, except for redirect URIs with a custom scheme (`myapp://callback`), which imply `native`. Clients that only implement RFC 7591 do not send this member. |
+| `client_name` | Optional, human-readable name. |
+| `post_logout_redirect_uris` | Optional. |
+
+Metadata errors are returned as defined in [RFC 7591 §3.2.2](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2),
+for example `invalid_redirect_uri` or `invalid_client_metadata`, with HTTP status `400`. Authorization failures use the
+[RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) codes `invalid_token` (`401`) and
+`insufficient_scope` (`403`).
+
+## Limitations
+
+This first version intentionally keeps a small scope:
+
+- Free-form metadata such as `logo_uri`, `client_uri` or `contacts` is accepted but not persisted.
+- The `private_key_jwt` authentication method and the `jwks`/`jwks_uri` members are rejected with `invalid_client_metadata`.
+- Reading, updating and deleting a registration ([RFC 7592](https://datatracker.ietf.org/doc/html/rfc7592)) is not yet supported.
+- Dynamically registered clients share the audience of the `ZITADEL DCR` project. A JWT access token issued to one of
+ them carries every client ID registered in that project, plus the project ID, in its `aud` claim. The `resource`
+ parameter ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) is accepted on the authorization code flow but
+ ignored, so it does not narrow `aud`. Do not rely on `aud` alone to tell one dynamically registered client from
+ another; validate the `client_id` or `azp` claim instead.
diff --git a/apps/docs/content/guides/manage/console/administrators.mdx b/apps/docs/content/guides/manage/console/administrators.mdx
index f70e26206..b86f27248 100644
--- a/apps/docs/content/guides/manage/console/administrators.mdx
+++ b/apps/docs/content/guides/manage/console/administrators.mdx
@@ -40,6 +40,7 @@ In the right part of the management console you can find **ADMINISTRATORS** in t
| Org User Permission Editor | ORG_USER_PERMISSION_EDITOR | Manage user grants and view everything needed for this |
| Org Project Permission Editor | ORG_PROJECT_PERMISSION_EDITOR | Grant Projects to other organizations and view everything needed for this |
| Org Project Creator | ORG_PROJECT_CREATOR | This role is used for users in the global organization. They are allowed to create projects and manage them. |
+| Org Dynamic Client Registrar | ORG_DYNAMIC_CLIENT_REGISTRAR | Register OAuth 2.0 clients through the [dynamic client registration](/guides/integrate/dynamic-client-registration) endpoint, and nothing else |
| Org Admin Impersonator | ORG_ADMIN_IMPERSONATOR | Allow impersonation of admin and end users from the organization |
| Org Impersonator | ORG_END_USER_IMPERSONATOR | Allow impersonation of end users from the organization |
| Project Owner | PROJECT_OWNER | Manage everything within a project. This includes to grant users for the project. |
diff --git a/backend/v3/domain/mock/security_settings.mock.go b/backend/v3/domain/mock/security_settings.mock.go
index b63e1cb6e..f7de776c2 100644
--- a/backend/v3/domain/mock/security_settings.mock.go
+++ b/backend/v3/domain/mock/security_settings.mock.go
@@ -3,7 +3,7 @@
//
// Generated by this command:
//
-// mockgen -typed -package domainmock -destination ./mock/security_settings.mock.go . SecuritySettingsRepository
+// mockgen -typed -package domainmock -destination ./mock/security_settings.mock.go github.com/zitadel/zitadel/backend/v3/domain SecuritySettingsRepository
//
// Package domainmock is a generated GoMock package.
@@ -551,6 +551,44 @@ func (c *MockSecuritySettingsRepositorySetCall) DoAndReturn(f func(context.Conte
return c
}
+// SetAllowUnauthenticatedDynamicClientRegistration mocks base method.
+func (m *MockSecuritySettingsRepository) SetAllowUnauthenticatedDynamicClientRegistration(value bool) json.JsonUpdate {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SetAllowUnauthenticatedDynamicClientRegistration", value)
+ ret0, _ := ret[0].(json.JsonUpdate)
+ return ret0
+}
+
+// SetAllowUnauthenticatedDynamicClientRegistration indicates an expected call of SetAllowUnauthenticatedDynamicClientRegistration.
+func (mr *MockSecuritySettingsRepositoryMockRecorder) SetAllowUnauthenticatedDynamicClientRegistration(value any) *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAllowUnauthenticatedDynamicClientRegistration", reflect.TypeOf((*MockSecuritySettingsRepository)(nil).SetAllowUnauthenticatedDynamicClientRegistration), value)
+ return &MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall{Call: call}
+}
+
+// MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall wrap *gomock.Call
+type MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall) Return(arg0 json.JsonUpdate) *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall) Do(f func(bool) json.JsonUpdate) *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall) DoAndReturn(f func(bool) json.JsonUpdate) *MockSecuritySettingsRepositorySetAllowUnauthenticatedDynamicClientRegistrationCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
// SetAllowedOrigins mocks base method.
func (m *MockSecuritySettingsRepository) SetAllowedOrigins(values []string) json.JsonUpdate {
m.ctrl.T.Helper()
@@ -589,6 +627,44 @@ func (c *MockSecuritySettingsRepositorySetAllowedOriginsCall) DoAndReturn(f func
return c
}
+// SetEnableDynamicClientRegistration mocks base method.
+func (m *MockSecuritySettingsRepository) SetEnableDynamicClientRegistration(value bool) json.JsonUpdate {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SetEnableDynamicClientRegistration", value)
+ ret0, _ := ret[0].(json.JsonUpdate)
+ return ret0
+}
+
+// SetEnableDynamicClientRegistration indicates an expected call of SetEnableDynamicClientRegistration.
+func (mr *MockSecuritySettingsRepositoryMockRecorder) SetEnableDynamicClientRegistration(value any) *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEnableDynamicClientRegistration", reflect.TypeOf((*MockSecuritySettingsRepository)(nil).SetEnableDynamicClientRegistration), value)
+ return &MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall{Call: call}
+}
+
+// MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall wrap *gomock.Call
+type MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall) Return(arg0 json.JsonUpdate) *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall) Do(f func(bool) json.JsonUpdate) *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall) DoAndReturn(f func(bool) json.JsonUpdate) *MockSecuritySettingsRepositorySetEnableDynamicClientRegistrationCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
// SetEnableIframeEmbedding mocks base method.
func (m *MockSecuritySettingsRepository) SetEnableIframeEmbedding(value bool) json.JsonUpdate {
m.ctrl.T.Helper()
diff --git a/backend/v3/domain/settings.go b/backend/v3/domain/settings.go
index 1691b725d..952a300cc 100644
--- a/backend/v3/domain/settings.go
+++ b/backend/v3/domain/settings.go
@@ -324,6 +324,8 @@ type securitySettingsJSONChanges interface {
SetEnableIframeEmbedding(value bool) db_json.JsonUpdate
SetAllowedOrigins(values []string) db_json.JsonUpdate
SetEnableImpersonation(value bool) db_json.JsonUpdate
+ SetEnableDynamicClientRegistration(value bool) db_json.JsonUpdate
+ SetAllowUnauthenticatedDynamicClientRegistration(value bool) db_json.JsonUpdate
}
type SecuritySettings struct {
@@ -335,6 +337,9 @@ type SecuritySettingsAttributes struct {
EnableIframeEmbedding *bool `json:"enableIframeEmbedding,omitempty"`
AllowedOrigins []string `json:"allowedOrigins,omitempty"`
EnableImpersonation *bool `json:"enableImpersonation,omitempty"`
+
+ EnableDynamicClientRegistration *bool `json:"enableDynamicClientRegistration,omitempty"`
+ AllowUnauthenticatedDynamicClientRegistration *bool `json:"allowUnauthenticatedDynamicClientRegistration,omitempty"`
}
//go:generate mockgen -typed -package domainmock -destination ./mock/security_settings.mock.go . SecuritySettingsRepository
diff --git a/backend/v3/storage/database/repository/settings.go b/backend/v3/storage/database/repository/settings.go
index 29187c746..1b2577802 100644
--- a/backend/v3/storage/database/repository/settings.go
+++ b/backend/v3/storage/database/repository/settings.go
@@ -977,6 +977,12 @@ func (s securitySettings) SetSettingFields(value domain.SecuritySettingsAttribut
if value.EnableImpersonation != nil {
changes = append(changes, s.SetEnableImpersonation(*value.EnableImpersonation))
}
+ if value.EnableDynamicClientRegistration != nil {
+ changes = append(changes, s.SetEnableDynamicClientRegistration(*value.EnableDynamicClientRegistration))
+ }
+ if value.AllowUnauthenticatedDynamicClientRegistration != nil {
+ changes = append(changes, s.SetAllowUnauthenticatedDynamicClientRegistration(*value.AllowUnauthenticatedDynamicClientRegistration))
+ }
return db_json.NewJsonChanges(s.SettingsColumn(), changes...)
}
@@ -992,6 +998,14 @@ func (securitySettings) SetEnableImpersonation(value bool) db_json.JsonUpdate {
return db_json.NewFieldChange([]string{"enableImpersonation"}, value)
}
+func (securitySettings) SetEnableDynamicClientRegistration(value bool) db_json.JsonUpdate {
+ return db_json.NewFieldChange([]string{"enableDynamicClientRegistration"}, value)
+}
+
+func (securitySettings) SetAllowUnauthenticatedDynamicClientRegistration(value bool) db_json.JsonUpdate {
+ return db_json.NewFieldChange([]string{"allowUnauthenticatedDynamicClientRegistration"}, value)
+}
+
func SecuritySettingsRepository() domain.SecuritySettingsRepository {
return &securitySettings{
settings{},
diff --git a/backend/v3/storage/database/repository/settings_test.go b/backend/v3/storage/database/repository/settings_test.go
index 3b3b0813b..14a6b8e72 100644
--- a/backend/v3/storage/database/repository/settings_test.go
+++ b/backend/v3/storage/database/repository/settings_test.go
@@ -1,7 +1,9 @@
package repository_test
import (
+ "fmt"
"net/url"
+ "strings"
"testing"
"time"
@@ -5178,3 +5180,26 @@ func TestSecretGeneratorSettingsInviteCodeRoundTrip(t *testing.T) {
require.NotNil(t, list[0].InviteCode)
assert.EqualExportedValues(t, want.InviteCode, list[0].InviteCode)
}
+
+// A security settings event may carry only the dynamic client registration fields, because
+// the command emits just the members that actually changed. Every field must therefore be
+// mapped in SetSettingFields: an unmapped one yields an empty change set, and
+// jsonChanges.Write only guards against a nil slice, so it panics on an empty one.
+func TestSetSecuritySettingsFields_dynamicClientRegistrationOnly(t *testing.T) {
+ t.Parallel()
+
+ change := repository.SecuritySettingsRepository().SetSettingFields(domain.SecuritySettingsAttributes{
+ EnableDynamicClientRegistration: gu.Ptr(true),
+ AllowUnauthenticatedDynamicClientRegistration: gu.Ptr(true),
+ })
+
+ builder := database.NewStatementBuilder("")
+ require.NotPanics(t, func() {
+ require.NoError(t, change.Write(builder))
+ })
+ // The field paths are bound as arguments, so assert on those rather than on the SQL.
+ assert.Equal(t, 2, strings.Count(builder.String(), "jsonb_set_lax"))
+ args := fmt.Sprint(builder.Args()...)
+ assert.Contains(t, args, "enableDynamicClientRegistration")
+ assert.Contains(t, args, "allowUnauthenticatedDynamicClientRegistration")
+}
diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml
index a4009463f..294e63d2a 100644
--- a/cmd/defaults.yaml
+++ b/cmd/defaults.yaml
@@ -669,6 +669,10 @@ OIDC:
Path: /oauth/v2/keys # ZITADEL_OIDC_CUSTOMENDPOINTS_KEYS_PATH
DeviceAuth:
Path: /oauth/v2/device_authorization # ZITADEL_OIDC_CUSTOMENDPOINTS_DEVICEAUTH_PATH
+ # OAuth 2.0 Dynamic Client Registration (RFC 7591). The endpoint is only served and
+ # advertised when it is enabled in the instance's security settings.
+ Registration:
+ Path: /oauth/v2/register # ZITADEL_OIDC_CUSTOMENDPOINTS_REGISTRATION_PATH
DeviceAuth:
Lifetime: 5m # ZITADEL_OIDC_DEVICEAUTH_LIFETIME
PollInterval: 5s # ZITADEL_OIDC_DEVICEAUTH_POLLINTERVAL
@@ -1667,6 +1671,7 @@ InternalAuthZ:
- "project.role.delete"
- "project.app.read"
- "project.app.write"
+ - "project.app.register_dynamic"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
@@ -1782,6 +1787,7 @@ InternalAuthZ:
- "project.role.delete"
- "project.app.read"
- "project.app.write"
+ - "project.app.register_dynamic"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
@@ -1884,6 +1890,7 @@ InternalAuthZ:
- "project.role.delete"
- "project.app.read"
- "project.app.write"
+ - "project.app.register_dynamic"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
@@ -2039,6 +2046,9 @@ InternalAuthZ:
- "policy.read"
- "project.read:self"
- "project.create"
+ - Role: "ORG_DYNAMIC_CLIENT_REGISTRAR"
+ Permissions:
+ - "project.app.register_dynamic"
- Role: "ORG_ADMIN_IMPERSONATOR"
Permissions:
- "admin.impersonation"
@@ -2260,6 +2270,7 @@ SystemAuthZ:
- "project.role.delete"
- "project.app.read"
- "project.app.write"
+ - "project.app.register_dynamic"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
@@ -2375,6 +2386,7 @@ SystemAuthZ:
- "project.role.delete"
- "project.app.read"
- "project.app.write"
+ - "project.app.register_dynamic"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json
index 438f856a8..11a0f3e0c 100644
--- a/console/src/assets/i18n/bg.json
+++ b/console/src/assets/i18n/bg.json
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Има разрешение за управление на ролеви назначения",
"ORG_PROJECT_PERMISSION_EDITOR": "Има разрешение за управление на грантове по проекти",
"ORG_PROJECT_CREATOR": "Има разрешение да създава свои собствени проекти и основни настройки",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Има разрешение за динамично регистриране на OAuth 2.0 клиенти",
"ORG_ADMIN_IMPERSONATOR": "Има разрешение да се представя за администратор и крайни потребители от организацията",
"ORG_END_USER_IMPERSONATOR": "Има разрешение да се представя за крайни потребители от организацията",
"ORG_USER_SELF_MANAGER": "Има разрешение да управлява собствените си потребители",
diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json
index 1211d7386..939e155a7 100644
--- a/console/src/assets/i18n/cs.json
+++ b/console/src/assets/i18n/cs.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Má oprávnění spravovat přiřazení rolí",
"ORG_PROJECT_PERMISSION_EDITOR": "Má oprávnění spravovat pověření projektu",
"ORG_PROJECT_CREATOR": "Má oprávnění vytvářet své vlastní projekty a podřízená nastavení",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Má oprávnění dynamicky registrovat klienty OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Má oprávnění vydávat se za správce a koncové uživatele z organizace",
"ORG_END_USER_IMPERSONATOR": "Má oprávnění vydávat se za koncové uživatele z organizace",
"ORG_USER_SELF_MANAGER": "Má oprávnění spravovat svůj vlastní uživatelský účet",
diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json
index bee10fa67..f780fb881 100644
--- a/console/src/assets/i18n/de.json
+++ b/console/src/assets/i18n/de.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Verfügt über die Berechtigung zum Verwalten von Rollen-Zuweisungen",
"ORG_PROJECT_PERMISSION_EDITOR": "Hat die Berechtigung, Projektberechtigungen für externe Organisationen zu verwalten",
"ORG_PROJECT_CREATOR": "Hat die Berechtigung, seine eigenen Projekte und dessen Einstellungen zu erstellen",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Hat die Berechtigung, OAuth-2.0-Clients dynamisch zu registrieren",
"ORG_ADMIN_IMPERSONATOR": "Hat die Berechtigung, sich als Administrator und Endbenutzer der Organisation auszugeben",
"ORG_END_USER_IMPERSONATOR": "Hat die Berechtigung, sich als Endbenutzer der Organisation auszugeben",
"ORG_USER_SELF_MANAGER": "Hat die Berechtigung, seinen eigenen Benutzer zu verwalten",
diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json
index 6919ca40a..a1669c236 100644
--- a/console/src/assets/i18n/en.json
+++ b/console/src/assets/i18n/en.json
@@ -665,6 +665,7 @@
"ORG_USER_PERMISSION_EDITOR": "Has permission to manage role assignments",
"ORG_PROJECT_PERMISSION_EDITOR": "Has permission to manage project grants",
"ORG_PROJECT_CREATOR": "Has permission to create his own projects and underlying settings",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Has permission to dynamically register OAuth 2.0 clients",
"ORG_ADMIN_IMPERSONATOR": "Has permission to impersonate admin and end users from the organization",
"ORG_END_USER_IMPERSONATOR": "Has permission to impersonate end users from the organization",
"ORG_USER_SELF_MANAGER": "Has permission to manage their own user",
diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json
index 8068c4df9..4304e2805 100644
--- a/console/src/assets/i18n/es.json
+++ b/console/src/assets/i18n/es.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Tiene permiso para gestionar asignaciones de roles",
"ORG_PROJECT_PERMISSION_EDITOR": "Tiene permiso para gestionar concesiones de proyecto",
"ORG_PROJECT_CREATOR": "Tiene permiso para crear sus propios proyectos y ajustes subyacentes",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Tiene permiso para registrar dinámicamente clientes OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Tiene permiso para hacerse pasar por administradores y usuarios finales de la organización",
"ORG_END_USER_IMPERSONATOR": "Tiene permiso para hacerse pasar por usuarios finales de la organización",
"ORG_USER_SELF_MANAGER": "Tiene permiso para gestionar su propio usuario",
diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json
index 759890071..e16a73d4d 100644
--- a/console/src/assets/i18n/fr.json
+++ b/console/src/assets/i18n/fr.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "A le droit de gérer les attributions de rôles",
"ORG_PROJECT_PERMISSION_EDITOR": "A le droit d'octroyer des droits aux projets",
"ORG_PROJECT_CREATOR": "A le droit de créer ses propres projets et leurs paramètres sous-jacents.",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "A le droit d'enregistrer dynamiquement des clients OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "A l'autorisation de se faire passer pour l'administrateur et les utilisateurs finaux de l'organisation",
"ORG_END_USER_IMPERSONATOR": "Est autorisé à usurper l'identité des utilisateurs finaux de l'organisation",
"ORG_USER_SELF_MANAGER": "A le droit de gérer ses propres utilisateurs",
diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json
index 1fd91b7e7..25efe6954 100644
--- a/console/src/assets/i18n/it.json
+++ b/console/src/assets/i18n/it.json
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Ha l'autorizzazione per gestire le assegnazioni ruoli",
"ORG_PROJECT_PERMISSION_EDITOR": "Ha il permesso di gestire le sovvenzioni di progetto (Project Grant)",
"ORG_PROJECT_CREATOR": "Ha il permesso di creare propri progetti e le impostazioni sottostanti",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Ha il permesso di registrare dinamicamente client OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Ha il permesso per rappresentare l'amministratore e gli utenti finali dell'organizzazione",
"ORG_END_USER_IMPERSONATOR": "Ha il permesso per rappresentare gli utenti finali dell'organizzazione",
"ORG_USER_SELF_MANAGER": "Ha il permesso per gestire il proprio account utente",
diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json
index d1ee46c57..dabf404f7 100644
--- a/console/src/assets/i18n/ja.json
+++ b/console/src/assets/i18n/ja.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "ロール割り当てを管理する権限を持ちます",
"ORG_PROJECT_PERMISSION_EDITOR": "プロジェクトグラントを管理する権限を持ちます",
"ORG_PROJECT_CREATOR": "所有するプロジェクトと配下の設定を作成する権限を持ちます",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "OAuth 2.0 クライアントを動的に登録する権限を持ちます",
"ORG_ADMIN_IMPERSONATOR": "組織の管理者およびエンドユーザーになりすます権限がある",
"ORG_END_USER_IMPERSONATOR": "組織のエンドユーザーになりすます権限がある",
"ORG_USER_SELF_MANAGER": "自身を管理する権限がある",
diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json
index 935267c99..d8f8d5062 100644
--- a/console/src/assets/i18n/mk.json
+++ b/console/src/assets/i18n/mk.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Има дозвола за менаџирање на доделените улоги",
"ORG_PROJECT_PERMISSION_EDITOR": "Има дозвола за менаџирање на овластувања на проекти",
"ORG_PROJECT_CREATOR": "Има дозвола за креирање на сопствени проекти и нивни подесувања",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Има дозвола за динамичко регистрирање на OAuth 2.0 клиенти",
"ORG_ADMIN_IMPERSONATOR": "Има дозвола да имитира администратор и крајни корисници од организацијата",
"ORG_END_USER_IMPERSONATOR": "Има дозвола да ги имитира крајните корисници од организацијата",
"ORG_USER_SELF_MANAGER": "Има дозвола за менаџирање на своите корисници",
diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json
index fd830587a..9cb338c7f 100644
--- a/console/src/assets/i18n/nl.json
+++ b/console/src/assets/i18n/nl.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Heeft toestemming om roltoewijzingen te beheren",
"ORG_PROJECT_PERMISSION_EDITOR": "Heeft toestemming om projecttoegang te beheren",
"ORG_PROJECT_CREATOR": "Heeft toestemming om zijn eigen projecten en onderliggende instellingen aan te maken",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Heeft toestemming om OAuth 2.0-clients dynamisch te registreren",
"ORG_ADMIN_IMPERSONATOR": "Heeft toestemming om de beheerder en eindgebruikers van de organisatie na te bootsen",
"ORG_END_USER_IMPERSONATOR": "Heeft toestemming om eindgebruikers van de organisatie na te bootsen",
"ORG_USER_SELF_MANAGER": "Heeft toestemming om zijn eigen gebruiker te beheren",
diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json
index 30c8bd798..11806ca69 100644
--- a/console/src/assets/i18n/pl.json
+++ b/console/src/assets/i18n/pl.json
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Ma uprawnienia do zarządzania przypisaniami ról",
"ORG_PROJECT_PERMISSION_EDITOR": "Ma uprawnienie do zarządzania uprawnieniami projektu",
"ORG_PROJECT_CREATOR": "Ma uprawnienie do tworzenia własnych projektów i podstawowych ustawień",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Ma uprawnienie do dynamicznego rejestrowania klientów OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Ma uprawnienia do podszywania się pod administratora i użytkowników końcowych z organizacji",
"ORG_END_USER_IMPERSONATOR": "Ma uprawnienia do podszywania się pod użytkowników końcowych z organizacji",
"ORG_USER_SELF_MANAGER": "Ma uprawnienie do zarządzania swoim własnym kontem użytkownika",
diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json
index fbb82161c..e65176774 100644
--- a/console/src/assets/i18n/pt.json
+++ b/console/src/assets/i18n/pt.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Tem permissão para gerenciar atribuições de funções",
"ORG_PROJECT_PERMISSION_EDITOR": "Tem permissão para gerenciar concessões de projetos",
"ORG_PROJECT_CREATOR": "Tem permissão para criar seus próprios projetos e configurações subjacentes",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Tem permissão para registrar dinamicamente clientes OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Tem permissão para se passar por administradores e usuários finais da organização",
"ORG_END_USER_IMPERSONATOR": "Tem permissão para se passar por usuários finais da organização",
"ORG_USER_SELF_MANAGER": "Tem permissão para gerenciar seu próprio usuário",
diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json
index ee6eaf976..8531d404a 100644
--- a/console/src/assets/i18n/ru.json
+++ b/console/src/assets/i18n/ru.json
@@ -668,6 +668,7 @@
"ORG_USER_PERMISSION_EDITOR": "Имеет разрешение на управление назначениями ролей",
"ORG_PROJECT_PERMISSION_EDITOR": "Имеет разрешение на управление допусками проекта",
"ORG_PROJECT_CREATOR": "Имеет разрешение на создание собственных проектов и базовых настроек",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "Имеет разрешение на динамическую регистрацию клиентов OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Имеет разрешение выдавать себя за администратора и конечных пользователей организации",
"ORG_END_USER_IMPERSONATOR": "Имеет разрешение выдавать себя за конечных пользователей организации",
"ORG_USER_SELF_MANAGER": "Имеет разрешение на управление своим собственным пользователем",
diff --git a/console/src/assets/i18n/tr.json b/console/src/assets/i18n/tr.json
index 7ffe7f66c..5538a0c8e 100644
--- a/console/src/assets/i18n/tr.json
+++ b/console/src/assets/i18n/tr.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Rol atamalarını yönetme izni var",
"ORG_PROJECT_PERMISSION_EDITOR": "Proje yetkilerini yönetme iznine sahiptir",
"ORG_PROJECT_CREATOR": "Kendi projelerini ve altyapı ayarlarını oluşturma iznine sahiptir",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "OAuth 2.0 istemcilerini dinamik olarak kaydetme iznine sahiptir",
"ORG_ADMIN_IMPERSONATOR": "Organizasyondan admin ve son kullanıcıların kimliğine bürünme iznine sahiptir",
"ORG_END_USER_IMPERSONATOR": "Organizasyondan son kullanıcıların kimliğine bürünme iznine sahiptir",
"ORG_USER_SELF_MANAGER": "Kendi kullanıcısını yönetme iznine sahiptir",
diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json
index 6bea72d8e..e7a9932c6 100644
--- a/console/src/assets/i18n/zh.json
+++ b/console/src/assets/i18n/zh.json
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "具有管理角色分配的权限",
"ORG_PROJECT_PERMISSION_EDITOR": "有权管理项目授权",
"ORG_PROJECT_CREATOR": "有权创建自己的项目和基础设置",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR": "有权动态注册 OAuth 2.0 客户端",
"ORG_ADMIN_IMPERSONATOR": "有权模拟组织的管理员和最终用户",
"ORG_END_USER_IMPERSONATOR": "有权模拟组织的最终用户",
"ORG_USER_SELF_MANAGER": "有权管理自己的用户",
diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go
index edcc0eb7f..722ee176b 100644
--- a/internal/api/authz/context_mock.go
+++ b/internal/api/authz/context_mock.go
@@ -22,6 +22,13 @@ func WithMockFeatures(features feature.Features) MockContextInstanceOpts {
}
}
+func WithMockDynamicClientRegistration(enabled, allowUnauthenticated bool) MockContextInstanceOpts {
+ return func(i *instance) {
+ i.enableDCR = enabled
+ i.allowUnauthenticatedDCR = allowUnauthenticated
+ }
+}
+
func WithMockProjectID(projectID string) MockContextInstanceOpts {
return func(i *instance) {
i.projectID = projectID
diff --git a/internal/api/authz/instance.go b/internal/api/authz/instance.go
index fe6c46b89..452d88bbd 100644
--- a/internal/api/authz/instance.go
+++ b/internal/api/authz/instance.go
@@ -26,6 +26,12 @@ type Instance interface {
DefaultOrganisationID() string
SecurityPolicyAllowedOrigins() []string
EnableImpersonation() bool
+ // EnableDynamicClientRegistration states if the OAuth 2.0 Dynamic Client Registration
+ // endpoint (RFC 7591) is served and advertised in the discovery document.
+ EnableDynamicClientRegistration() bool
+ // AllowUnauthenticatedDynamicClientRegistration states if clients may register without
+ // an access token. It implies EnableDynamicClientRegistration.
+ AllowUnauthenticatedDynamicClientRegistration() bool
Block() *bool
AuditLogRetention() *time.Duration
Features() feature.Features
@@ -41,15 +47,17 @@ type InstanceVerifier interface {
}
type instance struct {
- id string
- projectID string
- appID string
- clientID string
- orgID string
- defaultLanguage language.Tag
- allowedLanguages []language.Tag
- features feature.Features
- executionTargets target.Router
+ id string
+ projectID string
+ appID string
+ clientID string
+ orgID string
+ defaultLanguage language.Tag
+ allowedLanguages []language.Tag
+ features feature.Features
+ executionTargets target.Router
+ enableDCR bool
+ allowUnauthenticatedDCR bool
}
func (i *instance) Block() *bool {
@@ -96,6 +104,14 @@ func (i *instance) EnableImpersonation() bool {
return false
}
+func (i *instance) EnableDynamicClientRegistration() bool {
+ return i.enableDCR
+}
+
+func (i *instance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return i.enableDCR && i.allowUnauthenticatedDCR
+}
+
func (i *instance) Features() feature.Features {
return i.features
}
diff --git a/internal/api/authz/instance_test.go b/internal/api/authz/instance_test.go
index 769bd8861..73c5c877c 100644
--- a/internal/api/authz/instance_test.go
+++ b/internal/api/authz/instance_test.go
@@ -131,6 +131,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false
}
+func (m *mockInstance) EnableDynamicClientRegistration() bool {
+ return false
+}
+
+func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return false
+}
+
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}
@@ -138,3 +146,29 @@ func (m *mockInstance) Features() feature.Features {
func (m *mockInstance) ExecutionRouter() target.Router {
return target.NewRouter(nil)
}
+
+// Open registration must never be reachable while dynamic client registration itself is
+// off, so that a single setting is enough to close the endpoint.
+func Test_instance_dynamicClientRegistration(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ enabled bool
+ allowUnauthenticated bool
+ wantEnabled bool
+ wantAllowUnauthenticated bool
+ }{
+ {name: "disabled", enabled: false, allowUnauthenticated: false, wantEnabled: false, wantAllowUnauthenticated: false},
+ {name: "disabled but unauthenticated allowed", enabled: false, allowUnauthenticated: true, wantEnabled: false, wantAllowUnauthenticated: false},
+ {name: "enabled, token mode", enabled: true, allowUnauthenticated: false, wantEnabled: true, wantAllowUnauthenticated: false},
+ {name: "enabled, open registration", enabled: true, allowUnauthenticated: true, wantEnabled: true, wantAllowUnauthenticated: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ i := &instance{enableDCR: tt.enabled, allowUnauthenticatedDCR: tt.allowUnauthenticated}
+ assert.Equal(t, tt.wantEnabled, i.EnableDynamicClientRegistration())
+ assert.Equal(t, tt.wantAllowUnauthenticated, i.AllowUnauthenticatedDynamicClientRegistration())
+ })
+ }
+}
diff --git a/internal/api/grpc/management/integration_test/org_test.go b/internal/api/grpc/management/integration_test/org_test.go
index 51bb9273a..28ddac738 100644
--- a/internal/api/grpc/management/integration_test/org_test.go
+++ b/internal/api/grpc/management/integration_test/org_test.go
@@ -26,6 +26,7 @@ var iamRoles = []string{
"ORG_USER_PERMISSION_EDITOR",
"ORG_PROJECT_PERMISSION_EDITOR",
"ORG_PROJECT_CREATOR",
+ "ORG_DYNAMIC_CLIENT_REGISTRAR",
"ORG_USER_SELF_MANAGER",
"ORG_ADMIN_IMPERSONATOR",
"ORG_END_USER_IMPERSONATOR",
diff --git a/internal/api/grpc/server/middleware/instance_interceptor_test.go b/internal/api/grpc/server/middleware/instance_interceptor_test.go
index 0731e91f4..118b26ef1 100644
--- a/internal/api/grpc/server/middleware/instance_interceptor_test.go
+++ b/internal/api/grpc/server/middleware/instance_interceptor_test.go
@@ -309,6 +309,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false
}
+func (m *mockInstance) EnableDynamicClientRegistration() bool {
+ return false
+}
+
+func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return false
+}
+
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}
diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go
index 597cc5774..19b7e47ee 100644
--- a/internal/api/grpc/settings/v2/settings_converter.go
+++ b/internal/api/grpc/settings/v2/settings_converter.go
@@ -246,6 +246,10 @@ func securityPolicyToSettingsPb(policy *query.SecurityPolicy) *settings.Security
AllowedOrigins: policy.AllowedOrigins,
},
EnableImpersonation: policy.EnableImpersonation,
+ DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
+ Enabled: policy.EnableDynamicClientRegistration,
+ AllowUnauthenticated: policy.AllowUnauthenticatedDynamicClientRegistration,
+ },
}
}
@@ -254,6 +258,9 @@ func securitySettingsToCommand(req *settings.SetSecuritySettingsRequest) *comman
EnableIframeEmbedding: req.GetEmbeddedIframe().GetEnabled(),
AllowedOrigins: req.GetEmbeddedIframe().GetAllowedOrigins(),
EnableImpersonation: req.GetEnableImpersonation(),
+
+ EnableDynamicClientRegistration: req.GetDynamicClientRegistration().GetEnabled(),
+ AllowUnauthenticatedDynamicClientRegistration: req.GetDynamicClientRegistration().GetAllowUnauthenticated(),
}
}
diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go
index 408324abf..27d1b9e2f 100644
--- a/internal/api/grpc/settings/v2/settings_converter_test.go
+++ b/internal/api/grpc/settings/v2/settings_converter_test.go
@@ -536,20 +536,35 @@ func Test_securityPolicyToSettingsPb(t *testing.T) {
AllowedOrigins: []string{"foo", "bar"},
},
EnableImpersonation: true,
+ DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
+ Enabled: true,
+ AllowUnauthenticated: true,
+ },
}
got := securityPolicyToSettingsPb(&query.SecurityPolicy{
EnableIframeEmbedding: true,
AllowedOrigins: []string{"foo", "bar"},
EnableImpersonation: true,
+
+ EnableDynamicClientRegistration: true,
+ AllowUnauthenticatedDynamicClientRegistration: true,
})
assert.Equal(t, want, got)
}
+func Test_securityPolicyToSettingsPb_dynamicClientRegistrationDisabled(t *testing.T) {
+ got := securityPolicyToSettingsPb(&query.SecurityPolicy{})
+ assert.Equal(t, &settings.DynamicClientRegistrationSettings{}, got.GetDynamicClientRegistration())
+}
+
func Test_securitySettingsToCommand(t *testing.T) {
want := &command.SecurityPolicy{
EnableIframeEmbedding: true,
AllowedOrigins: []string{"foo", "bar"},
EnableImpersonation: true,
+
+ EnableDynamicClientRegistration: true,
+ AllowUnauthenticatedDynamicClientRegistration: true,
}
got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{
EmbeddedIframe: &settings.EmbeddedIframeSettings{
@@ -557,6 +572,18 @@ func Test_securitySettingsToCommand(t *testing.T) {
AllowedOrigins: []string{"foo", "bar"},
},
EnableImpersonation: true,
+ DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
+ Enabled: true,
+ AllowUnauthenticated: true,
+ },
})
assert.Equal(t, want, got)
}
+
+// A request that omits dynamic_client_registration must leave the feature off rather than
+// panicking on the nil message.
+func Test_securitySettingsToCommand_dynamicClientRegistrationOmitted(t *testing.T) {
+ got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{})
+ assert.False(t, got.EnableDynamicClientRegistration)
+ assert.False(t, got.AllowUnauthenticatedDynamicClientRegistration)
+}
diff --git a/internal/api/http/middleware/instance_interceptor_test.go b/internal/api/http/middleware/instance_interceptor_test.go
index a9eb70eb1..c6474e930 100644
--- a/internal/api/http/middleware/instance_interceptor_test.go
+++ b/internal/api/http/middleware/instance_interceptor_test.go
@@ -397,6 +397,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false
}
+func (m *mockInstance) EnableDynamicClientRegistration() bool {
+ return false
+}
+
+func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return false
+}
+
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}
diff --git a/internal/api/oidc/client_registration.go b/internal/api/oidc/client_registration.go
new file mode 100644
index 000000000..d6f87428e
--- /dev/null
+++ b/internal/api/oidc/client_registration.go
@@ -0,0 +1,559 @@
+package oidc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/muhlemmer/gu"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+)
+
+// registrationMaxBodyBytes bounds the size of a dynamic client registration request body.
+const registrationMaxBodyBytes = 100 * 1024
+
+// dynamicClientRegistration handles POST requests to the OAuth 2.0 Dynamic Client
+// Registration endpoint (RFC 7591). The route is always mounted; it only registers clients
+// when dynamic client registration is enabled in the instance's security settings,
+// otherwise it behaves as if the endpoint did not exist.
+func (s *Server) dynamicClientRegistration(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ if !authz.GetInstance(ctx).EnableDynamicClientRegistration() {
+ http.NotFound(w, r)
+ return
+ }
+
+ ctx, resourceOwner, err := s.dynamicClientRegistrationResourceOwner(ctx, r)
+ if err != nil {
+ switch {
+ // The caller authenticated but is not allowed to register clients: RFC 6750 §3.1
+ // insufficient_scope, rather than pretending the token itself is invalid.
+ case errors.Is(err, errRegistrationForbidden):
+ s.writeRegistrationForbidden(ctx, w)
+ // Only missing/invalid credentials are a 401; unexpected errors (e.g. a failed
+ // token lookup) must not be masked as invalid_token.
+ case zerrors.IsUnauthenticated(err), zerrors.IsPermissionDenied(err):
+ s.writeRegistrationUnauthorized(ctx, w)
+ default:
+ s.writeRegistrationServerError(ctx, w, err)
+ }
+ return
+ }
+
+ var req clientRegistrationRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, registrationMaxBodyBytes)).Decode(&req); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ s.writeRegistrationJSON(ctx, w, http.StatusRequestEntityTooLarge, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body is too large"))
+ return
+ }
+ s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body could not be parsed"))
+ return
+ }
+
+ app, regErr := req.toOIDCApp()
+ if regErr != nil {
+ s.writeRegistrationError(ctx, w, regErr)
+ return
+ }
+
+ projectID, err := s.ensureDCRProject(ctx, resourceOwner)
+ if err != nil {
+ s.writeRegistrationServerError(ctx, w, err)
+ return
+ }
+
+ registered, err := s.command.AddDynamicOIDCClient(ctx, projectID, resourceOwner, app)
+ if err != nil {
+ s.writeRegistrationCommandError(ctx, w, err)
+ return
+ }
+
+ s.writeRegistrationJSON(ctx, w, http.StatusCreated, newClientRegistrationResponse(registered, req.ClientName, time.Now().Unix()))
+}
+
+// dynamicClientRegistrationResourceOwner authorizes the registration and returns the
+// organization the client is homed in.
+//
+// In token mode (the default) the caller presents an access token, the client is homed in
+// the token's organization and the token's user must hold the org-scoped
+// project.app.register_dynamic permission there. That permission is deliberately not
+// project.app.write: a service user can be allowed to self-register clients — through the
+// dedicated ORG_DYNAMIC_CLIENT_REGISTRAR role — without gaining write access to existing
+// applications.
+//
+// In open mode (allow_unauthenticated in the instance's security settings) no token is
+// required and no permission is checked, as required by the Model Context Protocol flow.
+// Those clients are homed in the instance's default organization.
+//
+// The check runs before any state is created, so it also gates the auto-provisioning of the
+// organization's dedicated DCR project.
+func (s *Server) dynamicClientRegistrationResourceOwner(ctx context.Context, r *http.Request) (context.Context, string, error) {
+ token := bearerToken(r)
+ if token == "" {
+ if !authz.GetInstance(ctx).AllowUnauthenticatedDynamicClientRegistration() {
+ return ctx, "", zerrors.ThrowUnauthenticated(nil, "OIDC-Eich8", "Errors.Token.Invalid")
+ }
+ return ctx, authz.GetInstance(ctx).DefaultOrganisationID(), nil
+ }
+
+ accessToken, err := s.verifyAccessToken(ctx, token)
+ if err != nil {
+ return ctx, "", err
+ }
+ // The permission check resolves the caller's memberships from the context, which the
+ // OIDC endpoints do not populate: unlike the gRPC APIs they are not behind the
+ // authorization interceptor. Derive it from the verified token, as the token exchange
+ // does for the impersonating actor.
+ ctx = authz.SetCtxData(ctx, authz.CtxData{
+ UserID: accessToken.userID,
+ OrgID: accessToken.resourceOwner,
+ })
+ if err := s.command.CheckPermissionRegisterDynamicClient(ctx, accessToken.resourceOwner); err != nil {
+ // verifyAccessToken also reports invalid tokens as permission denied, so mark the
+ // authorization failure explicitly to keep 401 and 403 apart.
+ return ctx, "", fmt.Errorf("%w: %w", errRegistrationForbidden, err)
+ }
+ return ctx, accessToken.resourceOwner, nil
+}
+
+// errRegistrationForbidden marks a caller that presented a valid access token but does not
+// hold the project.app.register_dynamic permission in the token's organization.
+var errRegistrationForbidden = errors.New("dynamic client registration not permitted")
+
+// ensureDCRProject returns the dedicated project that holds dynamically registered clients
+// for the organization, creating it on first use. The common case (the project already
+// exists) is served from the projection; the creation and the concurrent-creation race are
+// delegated to the command, which resolves them strongly consistently from the eventstore.
+func (s *Server) ensureDCRProject(ctx context.Context, resourceOwner string) (string, error) {
+ projectID, err := s.dcrProjectIDFromProjection(ctx, resourceOwner)
+ if err != nil || projectID != "" {
+ return projectID, err
+ }
+ return s.command.EnsureDCRProject(ctx, resourceOwner)
+}
+
+func (s *Server) dcrProjectIDFromProjection(ctx context.Context, resourceOwner string) (string, error) {
+ nameQuery, err := query.NewProjectNameSearchQuery(query.TextEquals, command.DCRProjectName)
+ if err != nil {
+ return "", err
+ }
+ ownerQuery, err := query.NewProjectResourceOwnerSearchQuery(resourceOwner)
+ if err != nil {
+ return "", err
+ }
+ // No permission check: access to the registration endpoint is the authorization
+ // boundary for dynamic client registration.
+ projects, err := s.query.SearchProjects(ctx, &query.ProjectSearchQueries{Queries: []query.SearchQuery{nameQuery, ownerQuery}}, nil)
+ if err != nil {
+ return "", err
+ }
+ if len(projects.Projects) == 0 {
+ return "", nil
+ }
+ return projects.Projects[0].ID, nil
+}
+
+func bearerToken(r *http.Request) string {
+ auth := r.Header.Get("Authorization")
+ scheme, token, found := strings.Cut(auth, " ")
+ if !found || !strings.EqualFold(scheme, "Bearer") {
+ return ""
+ }
+ return strings.TrimSpace(token)
+}
+
+func (s *Server) writeRegistrationJSON(ctx context.Context, w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Pragma", "no-cache")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration: encode response", "err", err)
+ }
+}
+
+func (s *Server) writeRegistrationError(ctx context.Context, w http.ResponseWriter, regErr *registrationError) {
+ s.writeRegistrationJSON(ctx, w, http.StatusBadRequest, regErr)
+}
+
+func (s *Server) writeRegistrationUnauthorized(ctx context.Context, w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
+ s.writeRegistrationJSON(ctx, w, http.StatusUnauthorized, newRegistrationError("invalid_token", "a valid access token is required to register a client"))
+}
+
+func (s *Server) writeRegistrationForbidden(ctx context.Context, w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`)
+ s.writeRegistrationJSON(ctx, w, http.StatusForbidden, newRegistrationError("insufficient_scope", "the access token is not allowed to register clients"))
+}
+
+func (s *Server) writeRegistrationServerError(ctx context.Context, w http.ResponseWriter, err error) {
+ s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration", "err", err)
+ s.writeRegistrationJSON(ctx, w, http.StatusInternalServerError, newRegistrationError("server_error", "the client could not be registered"))
+}
+
+func (s *Server) writeRegistrationCommandError(ctx context.Context, w http.ResponseWriter, err error) {
+ if zerrors.IsErrorInvalidArgument(err) {
+ s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported"))
+ return
+ }
+ s.writeRegistrationServerError(ctx, w, err)
+}
+
+// OAuth 2.0 Dynamic Client Registration error codes as defined in RFC 7591 §3.2.2.
+const (
+ registrationErrorInvalidRedirectURI = "invalid_redirect_uri"
+ registrationErrorInvalidClientMetadata = "invalid_client_metadata"
+)
+
+// registrationError is an OAuth 2.0 Dynamic Client Registration error response
+// (RFC 7591 §3.2.2). Invalid client metadata is returned with HTTP status 400; the same
+// shape is reused for the 401 (invalid_token) and 500 (server_error) responses.
+type registrationError struct {
+ ErrorType string `json:"error"`
+ ErrorDescription string `json:"error_description,omitempty"`
+}
+
+func (e *registrationError) Error() string {
+ if e.ErrorDescription == "" {
+ return e.ErrorType
+ }
+ return e.ErrorType + ": " + e.ErrorDescription
+}
+
+func newRegistrationError(errorType, description string) *registrationError {
+ return ®istrationError{ErrorType: errorType, ErrorDescription: description}
+}
+
+// clientRegistrationRequest holds the client metadata of an OAuth 2.0 Dynamic Client
+// Registration request (RFC 7591 §2, extended by OpenID Connect Dynamic Client
+// Registration 1.0 §2). Only the members relevant for a ZITADEL OIDC application are
+// interpreted. Free-form metadata (client_uri, logo_uri, contacts, etc.) is accepted but
+// not persisted in this version. Members that imply unsupported behaviour (jwks/jwks_uri,
+// private_key_jwt) are rejected explicitly.
+type clientRegistrationRequest struct {
+ RedirectURIs []string `json:"redirect_uris,omitempty"`
+ ResponseTypes []string `json:"response_types,omitempty"`
+ GrantTypes []string `json:"grant_types,omitempty"`
+ ApplicationType string `json:"application_type,omitempty"`
+ ClientName string `json:"client_name,omitempty"`
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
+ PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"`
+ JWKsURI string `json:"jwks_uri,omitempty"`
+ JWKs json.RawMessage `json:"jwks,omitempty"`
+}
+
+// clientRegistrationResponse is the client information response of a successful
+// registration (RFC 7591 §3.2.1).
+type clientRegistrationResponse struct {
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret,omitempty"`
+ ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
+ ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"`
+
+ RedirectURIs []string `json:"redirect_uris,omitempty"`
+ ResponseTypes []string `json:"response_types,omitempty"`
+ GrantTypes []string `json:"grant_types,omitempty"`
+ ApplicationType string `json:"application_type,omitempty"`
+ ClientName string `json:"client_name,omitempty"`
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
+ PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"`
+}
+
+// toOIDCApp maps the registration request to a domain OIDC application, applying the RFC
+// 7591 defaults and rejecting unsupported metadata. The returned application is not yet
+// persisted; the application name and the client credentials are assigned by the command.
+func (req *clientRegistrationRequest) toOIDCApp() (*domain.OIDCApp, *registrationError) {
+ if req.JWKsURI != "" || len(req.JWKs) > 0 {
+ return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "jwks and jwks_uri are not supported")
+ }
+
+ redirectURIs := trimSpaceSlice(req.RedirectURIs)
+ if len(redirectURIs) == 0 {
+ return nil, newRegistrationError(registrationErrorInvalidRedirectURI, "at least one redirect_uri is required")
+ }
+
+ applicationType, regErr := registrationApplicationTypeToDomain(req.ApplicationType, redirectURIs)
+ if regErr != nil {
+ return nil, regErr
+ }
+ authMethod, regErr := registrationAuthMethodToDomain(req.TokenEndpointAuthMethod, applicationType)
+ if regErr != nil {
+ return nil, regErr
+ }
+ grantTypes, regErr := registrationGrantTypesToDomain(req.GrantTypes)
+ if regErr != nil {
+ return nil, regErr
+ }
+ responseTypes, regErr := registrationResponseTypesToDomain(req.ResponseTypes)
+ if regErr != nil {
+ return nil, regErr
+ }
+
+ app := &domain.OIDCApp{
+ AppName: strings.TrimSpace(req.ClientName),
+ RedirectUris: redirectURIs,
+ ResponseTypes: responseTypes,
+ GrantTypes: grantTypes,
+ ApplicationType: gu.Ptr(applicationType),
+ AuthMethodType: gu.Ptr(authMethod),
+ PostLogoutRedirectUris: trimSpaceSlice(req.PostLogoutRedirectURIs),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ }
+
+ if !app.IsValid() {
+ return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested grant and response type combination is not supported")
+ }
+ if compliance := domain.GetOIDCV1Compliance(app.ApplicationType, app.GrantTypes, app.AuthMethodType, app.RedirectUris); compliance.NoneCompliant {
+ return nil, registrationComplianceError(compliance)
+ }
+ return app, nil
+}
+
+// registrationComplianceError maps a domain compliance failure to the matching RFC 7591
+// error. Auth-method and grant/response combination problems are client metadata errors;
+// redirect URI scheme problems are reported as an invalid redirect URI. The internal
+// compliance keys (see internal/domain/application_oidc.go) are not exposed to the client.
+func registrationComplianceError(compliance *domain.Compliance) *registrationError {
+ for _, problem := range compliance.Problems {
+ if strings.Contains(problem, "AuthMethodType") || strings.Contains(problem, "GrantType") || strings.Contains(problem, "Combinations") {
+ return newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported")
+ }
+ }
+ return newRegistrationError(registrationErrorInvalidRedirectURI, "one or more redirect_uris are invalid for the requested application type")
+}
+
+func registrationAuthMethodToDomain(method string, applicationType domain.OIDCApplicationType) (domain.OIDCAuthMethodType, *registrationError) {
+ switch method {
+ case "":
+ // Default the auth method to the application type. Native and user-agent
+ // applications must be public (none); web applications default to
+ // client_secret_basic per RFC 7591 §2.
+ if applicationType == domain.OIDCApplicationTypeNative || applicationType == domain.OIDCApplicationTypeUserAgent {
+ return domain.OIDCAuthMethodTypeNone, nil
+ }
+ return domain.OIDCAuthMethodTypeBasic, nil
+ case "client_secret_basic":
+ return domain.OIDCAuthMethodTypeBasic, nil
+ case "client_secret_post":
+ return domain.OIDCAuthMethodTypePost, nil
+ case "none":
+ return domain.OIDCAuthMethodTypeNone, nil
+ default:
+ return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "token_endpoint_auth_method "+method+" is not supported")
+ }
+}
+
+func registrationGrantTypesToDomain(grantTypes []string) ([]domain.OIDCGrantType, *registrationError) {
+ if len(grantTypes) == 0 {
+ // RFC 7591 §2: authorization_code is the default.
+ return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, nil
+ }
+ mapped := make([]domain.OIDCGrantType, 0, len(grantTypes))
+ for _, grantType := range grantTypes {
+ switch grantType {
+ case "authorization_code":
+ mapped = append(mapped, domain.OIDCGrantTypeAuthorizationCode)
+ case "refresh_token":
+ mapped = append(mapped, domain.OIDCGrantTypeRefreshToken)
+ case "implicit":
+ mapped = append(mapped, domain.OIDCGrantTypeImplicit)
+ case "urn:ietf:params:oauth:grant-type:device_code":
+ mapped = append(mapped, domain.OIDCGrantTypeDeviceCode)
+ case "urn:ietf:params:oauth:grant-type:token-exchange":
+ mapped = append(mapped, domain.OIDCGrantTypeTokenExchange)
+ default:
+ return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "grant_type "+grantType+" is not supported")
+ }
+ }
+ return mapped, nil
+}
+
+func registrationResponseTypesToDomain(responseTypes []string) ([]domain.OIDCResponseType, *registrationError) {
+ if len(responseTypes) == 0 {
+ // RFC 7591 §2: code is the default.
+ return []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, nil
+ }
+ mapped := make([]domain.OIDCResponseType, 0, len(responseTypes))
+ for _, responseType := range responseTypes {
+ switch normalizeResponseType(responseType) {
+ case "code":
+ mapped = append(mapped, domain.OIDCResponseTypeCode)
+ case "id_token":
+ mapped = append(mapped, domain.OIDCResponseTypeIDToken)
+ case "id_token token":
+ mapped = append(mapped, domain.OIDCResponseTypeIDTokenToken)
+ default:
+ return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "response_type "+responseType+" is not supported")
+ }
+ }
+ return mapped, nil
+}
+
+// normalizeResponseType sorts the space separated values of a response_type so that the
+// order of "token" and "id_token" does not matter.
+func normalizeResponseType(responseType string) string {
+ fields := strings.Fields(responseType)
+ hasToken, hasIDToken, hasCode := false, false, false
+ for _, field := range fields {
+ switch field {
+ case "token":
+ hasToken = true
+ case "id_token":
+ hasIDToken = true
+ case "code":
+ hasCode = true
+ }
+ }
+ switch {
+ case hasCode && !hasToken && !hasIDToken:
+ return "code"
+ case hasIDToken && hasToken && !hasCode:
+ return "id_token token"
+ case hasIDToken && !hasToken && !hasCode:
+ return "id_token"
+ default:
+ return strings.Join(fields, " ")
+ }
+}
+
+// registrationApplicationTypeToDomain resolves the application type of a registration
+// request. An explicit application_type is always honoured; it is only inferred when the
+// member is absent.
+//
+// OpenID Connect Dynamic Client Registration 1.0 §2 defaults to web, but application_type is
+// an OpenID Connect member: clients that only implement RFC 7591, which does not define it,
+// omit it. Native hosts doing so (Cursor and other MCP clients) register custom-scheme
+// redirect URIs, which ZITADEL reserves for native applications, so the web default would
+// reject them for a redirect URI that is in fact valid for what they are. Infer native in
+// exactly that case; everything that is accepted today keeps its current type.
+func registrationApplicationTypeToDomain(applicationType string, redirectURIs []string) (domain.OIDCApplicationType, *registrationError) {
+ switch applicationType {
+ case "":
+ if domain.OIDCRedirectURIsRequireNative(redirectURIs) {
+ return domain.OIDCApplicationTypeNative, nil
+ }
+ return domain.OIDCApplicationTypeWeb, nil
+ case "web":
+ return domain.OIDCApplicationTypeWeb, nil
+ case "native":
+ return domain.OIDCApplicationTypeNative, nil
+ default:
+ return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "application_type "+applicationType+" is not supported")
+ }
+}
+
+// newClientRegistrationResponse builds the RFC 7591 §3.2.1 response from the registered
+// application. The client name is echoed from the request, as the human-readable name is
+// not persisted as-is in this version.
+func newClientRegistrationResponse(app *domain.OIDCApp, clientName string, issuedAt int64) *clientRegistrationResponse {
+ resp := &clientRegistrationResponse{
+ ClientID: app.ClientID,
+ ClientSecret: app.ClientSecretString,
+ ClientIDIssuedAt: issuedAt,
+ RedirectURIs: app.RedirectUris,
+ ResponseTypes: responseTypesToRegistration(app.ResponseTypes),
+ GrantTypes: grantTypesToRegistration(app.GrantTypes),
+ ApplicationType: applicationTypeToRegistration(gu.Value(app.ApplicationType)),
+ ClientName: strings.TrimSpace(clientName),
+ TokenEndpointAuthMethod: authMethodToRegistration(gu.Value(app.AuthMethodType)),
+ PostLogoutRedirectURIs: app.PostLogoutRedirectUris,
+ }
+ if app.ClientSecretString != "" {
+ // RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED if client_secret is
+ // issued. 0 means the secret never expires.
+ resp.ClientSecretExpiresAt = gu.Ptr(int64(0))
+ }
+ return resp
+}
+
+func authMethodToRegistration(authMethod domain.OIDCAuthMethodType) string {
+ switch authMethod {
+ case domain.OIDCAuthMethodTypeBasic:
+ return "client_secret_basic"
+ case domain.OIDCAuthMethodTypePost:
+ return "client_secret_post"
+ case domain.OIDCAuthMethodTypeNone:
+ return "none"
+ case domain.OIDCAuthMethodTypePrivateKeyJWT:
+ return "private_key_jwt"
+ default:
+ return ""
+ }
+}
+
+func grantTypesToRegistration(grantTypes []domain.OIDCGrantType) []string {
+ mapped := make([]string, 0, len(grantTypes))
+ for _, grantType := range grantTypes {
+ switch grantType {
+ case domain.OIDCGrantTypeAuthorizationCode:
+ mapped = append(mapped, "authorization_code")
+ case domain.OIDCGrantTypeRefreshToken:
+ mapped = append(mapped, "refresh_token")
+ case domain.OIDCGrantTypeImplicit:
+ mapped = append(mapped, "implicit")
+ case domain.OIDCGrantTypeDeviceCode:
+ mapped = append(mapped, "urn:ietf:params:oauth:grant-type:device_code")
+ case domain.OIDCGrantTypeTokenExchange:
+ mapped = append(mapped, "urn:ietf:params:oauth:grant-type:token-exchange")
+ }
+ }
+ return mapped
+}
+
+func responseTypesToRegistration(responseTypes []domain.OIDCResponseType) []string {
+ mapped := make([]string, 0, len(responseTypes))
+ for _, responseType := range responseTypes {
+ switch responseType {
+ case domain.OIDCResponseTypeCode:
+ mapped = append(mapped, "code")
+ case domain.OIDCResponseTypeIDToken:
+ mapped = append(mapped, "id_token")
+ case domain.OIDCResponseTypeIDTokenToken:
+ mapped = append(mapped, "id_token token")
+ case domain.OIDCResponseTypeUnspecified:
+ // not exposed through dynamic client registration
+ }
+ }
+ return mapped
+}
+
+func applicationTypeToRegistration(applicationType domain.OIDCApplicationType) string {
+ switch applicationType {
+ case domain.OIDCApplicationTypeWeb:
+ return "web"
+ case domain.OIDCApplicationTypeNative:
+ return "native"
+ case domain.OIDCApplicationTypeUserAgent:
+ return "user_agent"
+ default:
+ return ""
+ }
+}
+
+func trimSpaceSlice(values []string) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ trimmed := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ trimmed = append(trimmed, value)
+ }
+ }
+ if len(trimmed) == 0 {
+ return nil
+ }
+ return trimmed
+}
diff --git a/internal/api/oidc/client_registration_test.go b/internal/api/oidc/client_registration_test.go
new file mode 100644
index 000000000..643d8979d
--- /dev/null
+++ b/internal/api/oidc/client_registration_test.go
@@ -0,0 +1,370 @@
+package oidc
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+)
+
+func Test_clientRegistrationRequest_toOIDCApp(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ req *clientRegistrationRequest
+ want *domain.OIDCApp
+ wantErr string // expected registrationError.ErrorType, empty means success
+ }{
+ {
+ name: "defaults applied",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "explicit values mapped and trimmed",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{" https://app.example.com/callback "},
+ ResponseTypes: []string{"code"},
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ ApplicationType: "native",
+ ClientName: "My MCP Client",
+ TokenEndpointAuthMethod: "none",
+ PostLogoutRedirectURIs: []string{"https://app.example.com/logout"},
+ },
+ want: &domain.OIDCApp{
+ AppName: "My MCP Client",
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode, domain.OIDCGrantTypeRefreshToken},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ PostLogoutRedirectUris: []string{"https://app.example.com/logout"},
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "native application without auth method defaults to public client",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ ApplicationType: "native",
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "native application with custom scheme redirect",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"com.example.app:/callback"},
+ ApplicationType: "native",
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"com.example.app:/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "native application with confidential auth method rejected as client metadata",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ ApplicationType: "native",
+ TokenEndpointAuthMethod: "client_secret_basic",
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "client_secret_post auth method mapped",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ TokenEndpointAuthMethod: "client_secret_post",
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "native loopback http redirect allowed",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"http://127.0.0.1:8080/callback"},
+ ApplicationType: "native",
+ TokenEndpointAuthMethod: "none",
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"http://127.0.0.1:8080/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ name: "jwks_uri rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ JWKsURI: "https://app.example.com/jwks.json",
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "private_key_jwt rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ TokenEndpointAuthMethod: "private_key_jwt",
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "unknown grant_type rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ GrantTypes: []string{"client_credentials"},
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "unknown response_type rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ ResponseTypes: []string{"token"},
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "unknown application_type rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ ApplicationType: "service",
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ {
+ name: "missing redirect_uris rejected",
+ req: &clientRegistrationRequest{
+ ApplicationType: "web",
+ },
+ wantErr: registrationErrorInvalidRedirectURI,
+ },
+ {
+ name: "custom scheme redirect for web app rejected",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"myapp://callback"},
+ ApplicationType: "web",
+ },
+ wantErr: registrationErrorInvalidRedirectURI,
+ },
+ {
+ // Cursor and other native MCP hosts speak RFC 7591, which has no
+ // application_type, so they omit it and send a custom scheme.
+ name: "custom scheme redirect without application_type infers native",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"cursor://anysphere.cursor-retrieval/oauth/callback"},
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"cursor://anysphere.cursor-retrieval/oauth/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ // The inference must not widen anything else: an http loopback redirect stays
+ // a web application, exactly as before, and keeps its confidential default.
+ name: "loopback redirect without application_type stays web",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"http://localhost:8080/callback"},
+ },
+ want: &domain.OIDCApp{
+ RedirectUris: []string{"http://localhost:8080/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ {
+ // Inference only fills an absent member: a client that asks for a confidential
+ // auth method with a custom scheme is a genuine conflict, reported as such.
+ name: "custom scheme with confidential auth method rejected as client metadata",
+ req: &clientRegistrationRequest{
+ RedirectURIs: []string{"myapp://callback"},
+ TokenEndpointAuthMethod: "client_secret_basic",
+ },
+ wantErr: registrationErrorInvalidClientMetadata,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := tt.req.toOIDCApp()
+ if tt.wantErr != "" {
+ require.NotNil(t, err)
+ assert.Equal(t, tt.wantErr, err.ErrorType)
+ return
+ }
+ require.Nil(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_normalizeResponseType(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"code", "code"},
+ {"id_token", "id_token"},
+ {"id_token token", "id_token token"},
+ {"token id_token", "id_token token"},
+ {"token", "token"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.in, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, normalizeResponseType(tt.in))
+ })
+ }
+}
+
+func Test_Server_dynamicClientRegistrationResourceOwner_withoutToken(t *testing.T) {
+ t.Parallel()
+ request := httptest.NewRequest(http.MethodPost, "/oauth/v2/register", nil)
+
+ t.Run("no token and open registration disabled is unauthenticated", func(t *testing.T) {
+ t.Parallel()
+ s := &Server{}
+ ctx := authz.NewMockContext("instance", "org", "", authz.WithMockDynamicClientRegistration(true, false))
+ _, _, err := s.dynamicClientRegistrationResourceOwner(ctx, request)
+ require.Error(t, err)
+ assert.True(t, zerrors.IsUnauthenticated(err))
+ })
+
+ t.Run("no token and open registration enabled uses the default organization", func(t *testing.T) {
+ t.Parallel()
+ s := &Server{}
+ ctx := authz.NewMockContext("instance", "default-org", "", authz.WithMockDynamicClientRegistration(true, true))
+ _, resourceOwner, err := s.dynamicClientRegistrationResourceOwner(ctx, request)
+ require.NoError(t, err)
+ assert.Equal(t, "default-org", resourceOwner)
+ })
+}
+
+func Test_bearerToken(t *testing.T) {
+ t.Parallel()
+ newRequest := func(authorization string) *http.Request {
+ r := httptest.NewRequest(http.MethodPost, "/oauth/v2/register", nil)
+ if authorization != "" {
+ r.Header.Set("Authorization", authorization)
+ }
+ return r
+ }
+ tests := []struct {
+ name string
+ authorization string
+ want string
+ }{
+ {"no header", "", ""},
+ {"bearer token", "Bearer abc123", "abc123"},
+ {"case insensitive scheme", "bearer abc123", "abc123"},
+ {"basic auth ignored", "Basic dXNlcjpwYXNz", ""},
+ {"trims spaces", "Bearer abc123 ", "abc123"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, bearerToken(newRequest(tt.authorization)))
+ })
+ }
+}
+
+func Test_newClientRegistrationResponse(t *testing.T) {
+ t.Parallel()
+
+ t.Run("confidential client returns secret and expiry", func(t *testing.T) {
+ t.Parallel()
+ app := &domain.OIDCApp{
+ ClientID: "client1",
+ ClientSecretString: "secret",
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
+ }
+ got := newClientRegistrationResponse(app, "My Client", 1700000000)
+ assert.Equal(t, &clientRegistrationResponse{
+ ClientID: "client1",
+ ClientSecret: "secret",
+ ClientIDIssuedAt: 1700000000,
+ ClientSecretExpiresAt: gu.Ptr(int64(0)),
+ RedirectURIs: []string{"https://app.example.com/callback"},
+ ResponseTypes: []string{"code"},
+ GrantTypes: []string{"authorization_code"},
+ ApplicationType: "web",
+ ClientName: "My Client",
+ TokenEndpointAuthMethod: "client_secret_basic",
+ }, got)
+ })
+
+ t.Run("public client returns no secret nor expiry", func(t *testing.T) {
+ t.Parallel()
+ app := &domain.OIDCApp{
+ ClientID: "client2",
+ RedirectUris: []string{"https://app.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeNative),
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ }
+ got := newClientRegistrationResponse(app, "", 1700000000)
+ assert.Empty(t, got.ClientSecret)
+ assert.Nil(t, got.ClientSecretExpiresAt)
+ assert.Equal(t, "none", got.TokenEndpointAuthMethod)
+ assert.Equal(t, "native", got.ApplicationType)
+ })
+}
diff --git a/internal/api/oidc/integration_test/client_registration_test.go b/internal/api/oidc/integration_test/client_registration_test.go
new file mode 100644
index 000000000..c4f4cb3b2
--- /dev/null
+++ b/internal/api/oidc/integration_test/client_registration_test.go
@@ -0,0 +1,251 @@
+//go:build integration
+
+package oidc_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/integration"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
+)
+
+// TestServer_DynamicClientRegistration covers the OAuth 2.0 Dynamic Client Registration
+// endpoint (RFC 7591) in open mode, where no access token is required.
+func TestServer_DynamicClientRegistration(t *testing.T) {
+ issuer := Instance.OIDCIssuer()
+ enableDynamicClientRegistration(t, CTXIAM, Instance, true)
+
+ t.Run("discovery advertises the registration endpoint", func(t *testing.T) {
+ discovery := fetchDiscovery(t, issuer)
+ assert.Equal(t, issuer+"/oauth/v2/register", discovery.RegistrationEndpoint)
+ })
+
+ t.Run("public client without secret, usable for authorization code with PKCE", func(t *testing.T) {
+ status, body := registerDynamicClient(t, issuer, "", map[string]any{
+ "client_name": "integration public client",
+ "redirect_uris": []string{redirectURI},
+ "token_endpoint_auth_method": "none",
+ })
+ require.Equal(t, http.StatusCreated, status)
+ clientID, _ := body["client_id"].(string)
+ require.NotEmpty(t, clientID)
+ assert.Empty(t, body["client_secret"])
+
+ // The returned client_id must be usable for an authorization code + PKCE flow.
+ // The client and its dedicated project were just created, so wait for the
+ // projection to catch up before starting the authorization flow.
+ var authRequestID string
+ retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
+ require.EventuallyWithT(t, func(tt *assert.CollectT) {
+ _, id, err := Instance.CreateOIDCAuthRequest(CTX, clientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID)
+ assert.NoError(tt, err)
+ authRequestID = id
+ }, retryDuration, tick, "registered client not usable for authorization")
+
+ sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
+ linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: authRequestID,
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: sessionID,
+ SessionToken: sessionToken,
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ code := assertCodeResponse(t, linkResp.GetCallbackUrl())
+ tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
+ require.NoError(t, err)
+ assert.NotEmpty(t, tokens.AccessToken)
+
+ // An Authorization header is always treated as a registration token, even with open
+ // registration enabled: the request must not fall back to anonymous registration.
+ // This token belongs to an ordinary user without project.app.register_dynamic, so
+ // it is rejected rather than silently registering a client.
+ tokenStatus, tokenBody := registerDynamicClient(t, issuer, tokens.AccessToken, map[string]any{
+ "client_name": "integration token-mode client",
+ "redirect_uris": []string{redirectURI},
+ })
+ require.Equal(t, http.StatusForbidden, tokenStatus)
+ assert.Equal(t, "insufficient_scope", tokenBody["error"])
+ assert.Empty(t, tokenBody["client_id"])
+ })
+
+ t.Run("confidential client returns a secret", func(t *testing.T) {
+ status, body := registerDynamicClient(t, issuer, "", map[string]any{
+ "client_name": "integration confidential client",
+ "redirect_uris": []string{redirectURI},
+ "token_endpoint_auth_method": "client_secret_basic",
+ })
+ require.Equal(t, http.StatusCreated, status)
+ assert.NotEmpty(t, body["client_id"])
+ assert.NotEmpty(t, body["client_secret"])
+ })
+
+ t.Run("invalid redirect uri is rejected", func(t *testing.T) {
+ status, body := registerDynamicClient(t, issuer, "", map[string]any{
+ "redirect_uris": []string{"myapp://callback"},
+ "application_type": "web",
+ })
+ assert.Equal(t, http.StatusBadRequest, status)
+ assert.Equal(t, "invalid_redirect_uri", body["error"])
+ })
+
+ t.Run("malformed request body is rejected", func(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPost, issuer+"/oauth/v2/register", bytes.NewReader([]byte("{not valid json")))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ })
+
+ t.Run("missing redirect uri is rejected", func(t *testing.T) {
+ status, body := registerDynamicClient(t, issuer, "", map[string]any{
+ "client_name": "no redirects",
+ })
+ assert.Equal(t, http.StatusBadRequest, status)
+ assert.Equal(t, "invalid_redirect_uri", body["error"])
+ })
+}
+
+// TestServer_DynamicClientRegistration_tokenMode covers the default mode, where a caller
+// must present an access token whose user holds project.app.register_dynamic in the token's
+// organization.
+func TestServer_DynamicClientRegistration_tokenMode(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+ instance := integration.NewInstance(ctx)
+ iamCTX := instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
+ issuer := instance.OIDCIssuer()
+
+ enableDynamicClientRegistration(t, iamCTX, instance, false)
+
+ metadata := map[string]any{
+ "client_name": "token mode client",
+ "redirect_uris": []string{redirectURI},
+ "token_endpoint_auth_method": "none",
+ }
+
+ t.Run("without a token registration is unauthorized", func(t *testing.T) {
+ status, body := registerDynamicClient(t, issuer, "", metadata)
+ assert.Equal(t, http.StatusUnauthorized, status)
+ assert.Equal(t, "invalid_token", body["error"])
+ })
+
+ t.Run("a token without the permission is forbidden", func(t *testing.T) {
+ _, pat, err := instance.CreateMachineUserPATWithMembership(iamCTX)
+ require.NoError(t, err)
+
+ status, body := registerDynamicClient(t, issuer, pat, metadata)
+ assert.Equal(t, http.StatusForbidden, status)
+ assert.Equal(t, "insufficient_scope", body["error"])
+ })
+
+ // The dedicated least-privilege role carries project.app.register_dynamic and nothing
+ // else, so it is enough on its own to register a client.
+ t.Run("a token with ORG_DYNAMIC_CLIENT_REGISTRAR may register", func(t *testing.T) {
+ _, pat, err := instance.CreateMachineUserPATWithMembership(iamCTX, domain.RoleOrgDynamicClientRegistrar)
+ require.NoError(t, err)
+
+ var body map[string]any
+ retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
+ require.EventuallyWithT(t, func(tt *assert.CollectT) {
+ var status int
+ status, body = registerDynamicClient(t, issuer, pat, metadata)
+ assert.Equal(tt, http.StatusCreated, status)
+ }, retryDuration, tick, "membership not effective for registration")
+ assert.NotEmpty(t, body["client_id"])
+ })
+}
+
+// TestServer_DynamicClientRegistration_disabled verifies that with the setting off the
+// endpoint is neither advertised nor served.
+func TestServer_DynamicClientRegistration_disabled(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ instance := integration.NewInstance(ctx)
+ issuer := instance.OIDCIssuer()
+
+ discovery := fetchDiscovery(t, issuer)
+ assert.Empty(t, discovery.RegistrationEndpoint)
+
+ status, _ := registerDynamicClient(t, issuer, "", map[string]any{
+ "redirect_uris": []string{redirectURI},
+ })
+ assert.Equal(t, http.StatusNotFound, status)
+}
+
+// enableDynamicClientRegistration turns the endpoint on through the instance's security
+// settings and waits until the change is observable, i.e. until the projection behind the
+// cached instance has caught up and discovery advertises the endpoint.
+func enableDynamicClientRegistration(t *testing.T, ctx context.Context, instance *integration.Instance, allowUnauthenticated bool) {
+ t.Helper()
+ _, err := instance.Client.SettingsV2.SetSecuritySettings(ctx, &settings.SetSecuritySettingsRequest{
+ DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
+ Enabled: true,
+ AllowUnauthenticated: allowUnauthenticated,
+ },
+ })
+ require.NoError(t, err)
+
+ issuer := instance.OIDCIssuer()
+ retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute)
+ require.EventuallyWithT(t, func(tt *assert.CollectT) {
+ resp, err := http.Get(issuer + "/.well-known/openid-configuration")
+ if !assert.NoError(tt, err) {
+ return
+ }
+ defer resp.Body.Close()
+ var discovery oidc.DiscoveryConfiguration
+ if !assert.NoError(tt, json.NewDecoder(resp.Body).Decode(&discovery)) {
+ return
+ }
+ assert.Equal(tt, issuer+"/oauth/v2/register", discovery.RegistrationEndpoint)
+ }, retryDuration, tick, "registration endpoint not advertised")
+}
+
+func registerDynamicClient(t testing.TB, issuer, accessToken string, metadata map[string]any) (int, map[string]any) {
+ t.Helper()
+ raw, err := json.Marshal(metadata)
+ require.NoError(t, err)
+ req, err := http.NewRequest(http.MethodPost, issuer+"/oauth/v2/register", bytes.NewReader(raw))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ if accessToken != "" {
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ }()
+ var body map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&body)
+ return resp.StatusCode, body
+}
+
+func fetchDiscovery(t testing.TB, issuer string) oidc.DiscoveryConfiguration {
+ t.Helper()
+ resp, err := http.Get(issuer + "/.well-known/openid-configuration")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ var discovery oidc.DiscoveryConfiguration
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&discovery))
+ return discovery
+}
diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go
index d0d83e45b..839450481 100644
--- a/internal/api/oidc/op.go
+++ b/internal/api/oidc/op.go
@@ -7,6 +7,7 @@ import (
"net/http"
"time"
+ "github.com/go-chi/chi/v5"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
@@ -64,6 +65,7 @@ type EndpointConfig struct {
EndSession *Endpoint
Keys *Endpoint
DeviceAuth *Endpoint
+ Registration *Endpoint
}
type Endpoint struct {
@@ -191,10 +193,18 @@ func NewServer(
opCrypto: alg,
assetAPIPrefix: assets.AssetAPI(),
httpClient: httpClient,
+ registrationEndpoint: registrationEndpoint(config.CustomEndpoints),
}
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
- server.Handler = op.RegisterLegacyServer(server,
- server.authorizeCallbackHandler,
+
+ // We register the routes via op.RegisterServer (instead of op.RegisterLegacyServer)
+ // so that the dynamic client registration route can be added through the same
+ // WithSetRouter hook as the authorize callback. op.RegisterLegacyServer appends its
+ // own WithHTTPMiddleware after the caller options, which would violate chi's
+ // "all middlewares before routes" rule once we add a route via WithSetRouter.
+ // This mirrors op.RegisterLegacyServer; op.NewIssuerInterceptor reproduces the issuer
+ // middleware it would otherwise add.
+ server.Handler = op.RegisterServer(server, server.Endpoints(),
op.WithFallbackLogger(fallbackLogger),
op.WithHTTPMiddleware(
middleware.CallDurationHandler,
@@ -209,11 +219,37 @@ func NewServer(
http_utils.CopyHeadersToContext,
accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(config.CustomEndpoints)),
middleware.ActivityHandler,
- ))
+ op.NewIssuerInterceptor(server.IssuerFromRequest).Handler,
+ ),
+ op.WithSetRouter(func(r chi.Router) {
+ r.HandleFunc(server.Endpoints().Authorization.Relative()+authCallbackPathSuffix, server.authorizeCallbackHandler)
+ r.Method(http.MethodPost, server.registrationEndpoint.Relative(), http.HandlerFunc(server.dynamicClientRegistration))
+ }),
+ )
return server, nil
}
+// authCallbackPathSuffix mirrors the unexported suffix used by op.RegisterLegacyServer to
+// register the authorize callback handler under the authorization endpoint.
+// Keep in sync with github.com/zitadel/oidc/v3/pkg/op (authCallbackPathSuffix). The
+// existing authorization-flow integration tests exercise this route and would fail if the
+// library changed the suffix.
+const authCallbackPathSuffix = "/callback"
+
+// registrationEndpoint builds the dynamic client registration endpoint, optionally
+// overridden through the custom endpoint configuration.
+// registrationEndpoint resolves the OAuth 2.0 Dynamic Client Registration endpoint
+// (RFC 7591). The path is configured in defaults.yaml like the other OIDC endpoints, so it
+// can be overridden by environment variable; the literal below is only the fallback for a
+// Server constructed without endpoint configuration.
+func registrationEndpoint(endpointConfig *EndpointConfig) *op.Endpoint {
+ if endpointConfig != nil && endpointConfig.Registration != nil {
+ return op.NewEndpointWithURL(endpointConfig.Registration.Path, endpointConfig.Registration.URL)
+ }
+ return op.NewEndpoint("/oauth/v2/register")
+}
+
func ContextToIssuer(ctx context.Context) string {
return http_utils.DomainContext(ctx).Origin()
}
diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go
index e7ea89194..ed3aaacae 100644
--- a/internal/api/oidc/server.go
+++ b/internal/api/oidc/server.go
@@ -45,6 +45,8 @@ type Server struct {
assetAPIPrefix func(ctx context.Context) string
httpClient *http.Client
+
+ registrationEndpoint *op.Endpoint
}
func endpoints(endpointConfig *EndpointConfig) op.Endpoints {
@@ -176,8 +178,16 @@ func (s *Server) EndSession(ctx context.Context, r *op.Request[oidc.EndSessionRe
func (s *Server) createDiscoveryConfig(ctx context.Context, supportedUILocales oidc.Locales) *oidc.DiscoveryConfiguration {
issuer := op.IssuerFromContext(ctx)
+ // The registration endpoint is only advertised when dynamic client registration is
+ // enabled in the instance's security settings.
+ var registrationEndpoint string
+ if authz.GetInstance(ctx).EnableDynamicClientRegistration() {
+ registrationEndpoint = s.registrationEndpoint.Absolute(issuer)
+ }
+
return &oidc.DiscoveryConfiguration{
Issuer: issuer,
+ RegistrationEndpoint: registrationEndpoint,
AuthorizationEndpoint: s.Endpoints().Authorization.Absolute(issuer),
TokenEndpoint: s.Endpoints().Token.Absolute(issuer),
IntrospectionEndpoint: s.Endpoints().Introspection.Absolute(issuer),
diff --git a/internal/api/oidc/server_test.go b/internal/api/oidc/server_test.go
index 1e012e8e4..d17380e8c 100644
--- a/internal/api/oidc/server_test.go
+++ b/internal/api/oidc/server_test.go
@@ -8,6 +8,8 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"golang.org/x/text/language"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
)
func TestServer_createDiscoveryConfig(t *testing.T) {
@@ -121,3 +123,42 @@ func TestServer_createDiscoveryConfig(t *testing.T) {
})
}
}
+
+func TestServer_createDiscoveryConfig_registrationEndpoint(t *testing.T) {
+ s := &Server{
+ LegacyServer: op.NewLegacyServer(
+ func() *op.Provider {
+ //nolint:staticcheck
+ provider, _ := op.NewForwardedOpenIDProvider("path", &op.Config{}, nil)
+ return provider
+ }(),
+ op.Endpoints{Authorization: op.NewEndpoint("auth")},
+ ),
+ registrationEndpoint: op.NewEndpoint("register"),
+ }
+ tests := []struct {
+ name string
+ ctx context.Context
+ want string
+ }{
+ {
+ name: "setting disabled, endpoint not advertised",
+ ctx: op.ContextWithIssuer(context.Background(), "https://issuer.com"),
+ want: "",
+ },
+ {
+ name: "setting enabled, endpoint advertised",
+ ctx: op.ContextWithIssuer(
+ authz.NewMockContext("instance", "org", "", authz.WithMockDynamicClientRegistration(true, false)),
+ "https://issuer.com",
+ ),
+ want: "https://issuer.com/register",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := s.createDiscoveryConfig(tt.ctx, nil)
+ assert.Equal(t, tt.want, got.RegistrationEndpoint)
+ })
+ }
+}
diff --git a/internal/command/instance_policy_security.go b/internal/command/instance_policy_security.go
index 8869223f8..b6d03a43c 100644
--- a/internal/command/instance_policy_security.go
+++ b/internal/command/instance_policy_security.go
@@ -14,6 +14,9 @@ type SecurityPolicy struct {
EnableIframeEmbedding bool
AllowedOrigins []string
EnableImpersonation bool
+
+ EnableDynamicClientRegistration bool
+ AllowUnauthenticatedDynamicClientRegistration bool
}
func (c *Commands) SetSecurityPolicy(ctx context.Context, policy *SecurityPolicy) (*domain.ObjectDetails, error) {
diff --git a/internal/command/instance_policy_security_model.go b/internal/command/instance_policy_security_model.go
index be14bc159..f4061ca23 100644
--- a/internal/command/instance_policy_security_model.go
+++ b/internal/command/instance_policy_security_model.go
@@ -38,6 +38,12 @@ func (wm *InstanceSecurityPolicyWriteModel) Reduce() error {
if e.EnableImpersonation != nil {
wm.EnableImpersonation = *e.EnableImpersonation
}
+ if e.EnableDynamicClientRegistration != nil {
+ wm.EnableDynamicClientRegistration = *e.EnableDynamicClientRegistration
+ }
+ if e.AllowUnauthenticatedDynamicClientRegistration != nil {
+ wm.AllowUnauthenticatedDynamicClientRegistration = *e.AllowUnauthenticatedDynamicClientRegistration
+ }
}
}
return wm.WriteModel.Reduce()
@@ -59,7 +65,7 @@ func (wm *InstanceSecurityPolicyWriteModel) NewSetEvent(
aggregate *eventstore.Aggregate,
policy *SecurityPolicy,
) (*instance.SecurityPolicySetEvent, error) {
- changes := make([]instance.SecurityPolicyChanges, 0, 2)
+ changes := make([]instance.SecurityPolicyChanges, 0, 5)
var err error
if wm.EnableIframeEmbedding != policy.EnableIframeEmbedding {
@@ -71,6 +77,12 @@ func (wm *InstanceSecurityPolicyWriteModel) NewSetEvent(
if wm.EnableImpersonation != policy.EnableImpersonation {
changes = append(changes, instance.ChangeSecurityPolicyEnableImpersonation(policy.EnableImpersonation))
}
+ if wm.EnableDynamicClientRegistration != policy.EnableDynamicClientRegistration {
+ changes = append(changes, instance.ChangeSecurityPolicyEnableDynamicClientRegistration(policy.EnableDynamicClientRegistration))
+ }
+ if wm.AllowUnauthenticatedDynamicClientRegistration != policy.AllowUnauthenticatedDynamicClientRegistration {
+ changes = append(changes, instance.ChangeSecurityPolicyAllowUnauthenticatedDynamicClientRegistration(policy.AllowUnauthenticatedDynamicClientRegistration))
+ }
changeEvent, err := instance.NewSecurityPolicySetEvent(ctx, aggregate, changes)
if err != nil {
return nil, err
diff --git a/internal/command/main_test.go b/internal/command/main_test.go
index d112160f9..1cee2a8fa 100644
--- a/internal/command/main_test.go
+++ b/internal/command/main_test.go
@@ -225,6 +225,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false
}
+func (m *mockInstance) EnableDynamicClientRegistration() bool {
+ return false
+}
+
+func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return false
+}
+
func (m *mockInstance) Features() feature.Features {
return feature.Features{}
}
diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go
index b7dc03364..9e32df164 100644
--- a/internal/command/permission_checks.go
+++ b/internal/command/permission_checks.go
@@ -173,6 +173,18 @@ func (c *Commands) CheckPermissionOrganizationDelete(ctx context.Context, organi
return c.newPermissionCheck(ctx, domain.PermissionOrganizationDelete, org.AggregateType)(organizationID, organizationID)
}
+// CheckPermissionRegisterDynamicClient authorizes token-gated OAuth 2.0 Dynamic Client
+// Registration (RFC 7591) in the given organization. It is org-scoped and deliberately
+// distinct from project.app.write, so a service user can be granted the ability to
+// self-register clients without gaining write access to existing applications.
+//
+// It is called once by the registration endpoint before any state is created, so that it
+// also gates the auto-provisioning of the organization's dedicated DCR project. Open
+// registration does not require the permission; see the endpoint for the mode selection.
+func (c *Commands) CheckPermissionRegisterDynamicClient(ctx context.Context, organizationID string) error {
+ return c.newPermissionCheck(ctx, domain.PermissionProjectAppRegisterDynamic, org.AggregateType)(organizationID, organizationID)
+}
+
func (c *Commands) checkPermissionCreateGroup(ctx context.Context, resourceOwner, groupID string) error {
return c.newPermissionCheck(ctx, domain.PermissionGroupCreate, group.AggregateType)(resourceOwner, groupID)
}
diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go
index d1daa5ad1..1b0f32dfd 100644
--- a/internal/command/project_application_oidc.go
+++ b/internal/command/project_application_oidc.go
@@ -190,6 +190,15 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain
return nil, err
}
+ return c.pushOIDCApplication(ctx, addedApplication, oidcApp, appID)
+}
+
+// pushOIDCApplication writes the application-added and oidc-config-added events for a new
+// OIDC application onto the project aggregate. Authorization must already have been
+// performed by the caller: either through an app.write permission check (see
+// addOIDCApplicationWithID) or, for dynamic client registration, through the feature flag
+// and registration mode (see AddDynamicOIDCClient).
+func (c *Commands) pushOIDCApplication(ctx context.Context, addedApplication *OIDCApplicationWriteModel, oidcApp *domain.OIDCApp, appID string) (_ *domain.OIDCApp, err error) {
projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel)
oidcApp.AppID = appID
diff --git a/internal/command/project_application_oidc_dcr.go b/internal/command/project_application_oidc_dcr.go
new file mode 100644
index 000000000..2f3059f65
--- /dev/null
+++ b/internal/command/project_application_oidc_dcr.go
@@ -0,0 +1,207 @@
+package command
+
+import (
+ "context"
+ "strings"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/eventstore"
+ "github.com/zitadel/zitadel/internal/repository/project"
+ "github.com/zitadel/zitadel/internal/telemetry/tracing"
+ "github.com/zitadel/zitadel/internal/zerrors"
+)
+
+// DCRProjectName is the name of the dedicated project that holds clients created through
+// OAuth 2.0 Dynamic Client Registration (RFC 7591). It is auto-provisioned per
+// organization on the first registration.
+const DCRProjectName = "ZITADEL DCR"
+
+// EnsureDCRProject returns the dedicated project that holds dynamically registered OIDC
+// clients in the given organization, creating it on first use. Like AddDynamicOIDCClient it
+// does not perform its own permission check: the registration endpoint authorizes the whole
+// request up front, through the instance's security settings and, in token mode,
+// CheckPermissionRegisterDynamicClient.
+//
+// Both the existence check and the recovery from a concurrent creation read from the
+// eventstore, so that several clients self-registering into the same organization at the
+// same time converge on a single project: the per-organization uniqueness of the project
+// name lets exactly one push win, and the racing callers resolve the winner's id strongly
+// consistently. Resolving from the projection instead would be eventually consistent and
+// could still report no project in that race.
+func (c *Commands) EnsureDCRProject(ctx context.Context, resourceOwner string) (_ string, err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+
+ if resourceOwner == "" {
+ return "", zerrors.ThrowInvalidArgument(nil, "COMMAND-Oht9a", "Errors.ResourceOwnerMissing")
+ }
+
+ projectID, err := c.dcrProjectID(ctx, resourceOwner)
+ if err != nil {
+ return "", err
+ }
+ if projectID != "" {
+ return projectID, nil
+ }
+
+ projectID, err = c.idGenerator.Next()
+ if err != nil {
+ return "", err
+ }
+ wm, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner)
+ if err != nil {
+ return "", err
+ }
+ events := []eventstore.Command{
+ project.NewProjectAddedEvent(
+ ctx,
+ ProjectAggregateFromWriteModelWithCTX(ctx, &wm.WriteModel),
+ DCRProjectName,
+ false,
+ false,
+ false,
+ domain.PrivateLabelingSettingUnspecified,
+ ),
+ }
+ postCommit, err := c.projectCreatedMilestone(ctx, &events)
+ if err != nil {
+ return "", err
+ }
+ pushedEvents, err := c.eventstore.Push(ctx, events...)
+ if err != nil {
+ if zerrors.IsErrorAlreadyExists(err) {
+ // A concurrent registration created the project first; resolve the winner.
+ return c.dcrProjectID(ctx, resourceOwner)
+ }
+ return "", err
+ }
+ postCommit(ctx)
+ if err = AppendAndReduce(wm, pushedEvents...); err != nil {
+ return "", err
+ }
+ return projectID, nil
+}
+
+// dcrProjectID resolves the id of an organization's dedicated DCR project from the
+// eventstore (strongly consistent). It returns an empty string when the project does not
+// exist yet.
+func (c *Commands) dcrProjectID(ctx context.Context, resourceOwner string) (string, error) {
+ wm := newDCRProjectWriteModel(resourceOwner)
+ if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil {
+ return "", err
+ }
+ return wm.projectID, nil
+}
+
+// dcrProjectWriteModel resolves the dedicated DCR project of an organization by its name.
+type dcrProjectWriteModel struct {
+ eventstore.WriteModel
+ projectID string
+}
+
+func newDCRProjectWriteModel(resourceOwner string) *dcrProjectWriteModel {
+ return &dcrProjectWriteModel{
+ WriteModel: eventstore.WriteModel{ResourceOwner: resourceOwner},
+ }
+}
+
+// Reduce resolves the project by its current name. Renames are followed in both directions:
+// a project renamed away from DCRProjectName is dropped, and one renamed onto it is picked
+// up. Ignoring ProjectChanged would leave this write model pointing at a project that no
+// longer carries the name, and disagreeing with the name-based projection query the
+// registration endpoint uses first.
+func (wm *dcrProjectWriteModel) Reduce() error {
+ for _, event := range wm.Events {
+ switch e := event.(type) {
+ case *project.ProjectAddedEvent:
+ if e.Name == DCRProjectName {
+ wm.projectID = e.Aggregate().ID
+ }
+ case *project.ProjectChangeEvent:
+ if e.Name == nil {
+ continue
+ }
+ if *e.Name == DCRProjectName {
+ wm.projectID = e.Aggregate().ID
+ } else if e.Aggregate().ID == wm.projectID {
+ wm.projectID = ""
+ }
+ case *project.ProjectRemovedEvent:
+ if e.Aggregate().ID == wm.projectID {
+ wm.projectID = ""
+ }
+ }
+ }
+ return wm.WriteModel.Reduce()
+}
+
+func (wm *dcrProjectWriteModel) Query() *eventstore.SearchQueryBuilder {
+ return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
+ AwaitOpenTransactions().
+ ResourceOwner(wm.ResourceOwner).
+ AddQuery().
+ AggregateTypes(project.AggregateType).
+ EventTypes(project.ProjectAddedType, project.ProjectChangedType, project.ProjectRemovedType).
+ Builder()
+}
+
+// AddDynamicOIDCClient registers a new OIDC application through OAuth 2.0 Dynamic Client
+// Registration (RFC 7591). It reuses the regular OIDC application persistence
+// (pushOIDCApplication, i.e. the same application-added and oidc-config-added events) so a
+// dynamically registered client is an ordinary OIDC app and the whole token, authorization
+// and introspection flow keeps working unchanged.
+//
+// Unlike AddOIDCApplication it does NOT perform an app.write permission check. Dynamic
+// client registration is authorized once at the registration endpoint, before any state is
+// created: the instance's security settings decide whether the endpoint is served at all
+// and whether unauthenticated registration is allowed, and token mode additionally requires
+// the org-scoped project.app.register_dynamic permission (see
+// CheckPermissionRegisterDynamicClient). Requiring app.write here instead would mean every
+// self-registering client needs write access to the organization's existing applications.
+// The caller is responsible for providing the target project (see the registration
+// endpoint's ensureDCRProject helper) and the owning organization.
+func (c *Commands) AddDynamicOIDCClient(ctx context.Context, projectID, resourceOwner string, oidcApp *domain.OIDCApp) (_ *domain.OIDCApp, err error) {
+ ctx, span := tracing.NewSpan(ctx)
+ defer func() { span.EndWithError(err) }()
+
+ if oidcApp == nil || projectID == "" || resourceOwner == "" {
+ return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Eip3p", "Errors.Project.App.Invalid")
+ }
+ oidcApp.AggregateID = projectID
+
+ if !oidcApp.IsValid() {
+ return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Joh2a", "Errors.Project.App.Invalid")
+ }
+
+ if _, err = c.checkProjectExists(ctx, projectID, resourceOwner); err != nil {
+ return nil, err
+ }
+
+ appID, err := c.idGenerator.Next()
+ if err != nil {
+ return nil, err
+ }
+ // The application name carries a unique constraint per project. As the client name
+ // from the registration request is optional and not guaranteed to be unique (several
+ // MCP clients self-register into the same project), derive a unique name from the
+ // generated application ID.
+ oidcApp.AppName = dynamicOIDCClientName(oidcApp.AppName, appID)
+
+ addedApplication := NewOIDCApplicationWriteModel(projectID, resourceOwner)
+ if err = c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil {
+ return nil, err
+ }
+
+ return c.pushOIDCApplication(ctx, addedApplication, oidcApp, appID)
+}
+
+// dynamicOIDCClientName builds a per-project unique application name for a dynamically
+// registered client. The optional, non-unique client name from the request is kept for
+// readability and disambiguated with the unique application ID.
+func dynamicOIDCClientName(requestedName, appID string) string {
+ requestedName = strings.TrimSpace(requestedName)
+ if requestedName == "" {
+ return "DCR Client " + appID
+ }
+ return requestedName + " (" + appID + ")"
+}
diff --git a/internal/command/project_application_oidc_dcr_test.go b/internal/command/project_application_oidc_dcr_test.go
new file mode 100644
index 000000000..9f112c73f
--- /dev/null
+++ b/internal/command/project_application_oidc_dcr_test.go
@@ -0,0 +1,495 @@
+package command
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/eventstore"
+ "github.com/zitadel/zitadel/internal/eventstore/v1/models"
+ "github.com/zitadel/zitadel/internal/id"
+ id_mock "github.com/zitadel/zitadel/internal/id/mock"
+ "github.com/zitadel/zitadel/internal/repository/project"
+ "github.com/zitadel/zitadel/internal/zerrors"
+)
+
+func TestCommandSide_EnsureDCRProject(t *testing.T) {
+ t.Parallel()
+
+ dcrProjectAdded := func(projectID string) eventstore.Event {
+ return eventFromEventPusher(project.NewProjectAddedEvent(
+ context.Background(),
+ &project.NewAggregate(projectID, "org1").Aggregate,
+ DCRProjectName,
+ false,
+ false,
+ false,
+ domain.PrivateLabelingSettingUnspecified,
+ ))
+ }
+
+ t.Run("missing resource owner, invalid argument", func(t *testing.T) {
+ t.Parallel()
+ c := &Commands{
+ eventstore: expectEventstore()(t),
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ }
+ _, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "")
+ assert.True(t, zerrors.IsErrorInvalidArgument(err))
+ })
+
+ t.Run("existing project resolved from the eventstore", func(t *testing.T) {
+ t.Parallel()
+ c := &Commands{
+ eventstore: expectEventstore(
+ expectFilter(dcrProjectAdded("existing")),
+ )(t),
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ }
+ projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
+ assert.NoError(t, err)
+ assert.Equal(t, "existing", projectID)
+ })
+
+ t.Run("project created when none exists, without permission check", func(t *testing.T) {
+ t.Parallel()
+ c := &Commands{
+ eventstore: expectEventstore(
+ expectFilter(), // lookup: no DCR project yet
+ expectFilter(), // project write model for the new id
+ expectPush(
+ project.NewProjectAddedEvent(
+ context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ DCRProjectName,
+ false,
+ false,
+ false,
+ domain.PrivateLabelingSettingUnspecified,
+ ),
+ ),
+ )(t),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"),
+ // Provisioning the DCR project must not depend on project.create.
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ }
+ c.setMilestonesCompletedForTest("instanceID")
+ projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
+ assert.NoError(t, err)
+ assert.Equal(t, "project1", projectID)
+ })
+
+ t.Run("concurrent creation resolves the winner from the eventstore", func(t *testing.T) {
+ t.Parallel()
+ c := &Commands{
+ eventstore: expectEventstore(
+ expectFilter(), // lookup: no DCR project yet
+ expectFilter(), // project write model for the new id
+ expectPushFailed(
+ zerrors.ThrowAlreadyExists(nil, "id", "project name already taken"),
+ project.NewProjectAddedEvent(
+ context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ DCRProjectName,
+ false,
+ false,
+ false,
+ domain.PrivateLabelingSettingUnspecified,
+ ),
+ ),
+ expectFilter(dcrProjectAdded("winner")), // re-resolve: the racing winner
+ )(t),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"),
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ }
+ c.setMilestonesCompletedForTest("instanceID")
+ projectID, err := c.EnsureDCRProject(authz.WithInstanceID(context.Background(), "instanceID"), "org1")
+ assert.NoError(t, err)
+ assert.Equal(t, "winner", projectID)
+ })
+}
+
+func TestCommandSide_AddDynamicOIDCClient(t *testing.T) {
+ t.Parallel()
+
+ type fields struct {
+ eventstore func(t *testing.T) *eventstore.Eventstore
+ idGenerator id.Generator
+ }
+ type args struct {
+ ctx context.Context
+ projectID string
+ resourceOwner string
+ oidcApp *domain.OIDCApp
+ }
+ type res struct {
+ want *domain.OIDCApp
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "missing project id, invalid argument error",
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ args: args{
+ ctx: authz.WithInstanceID(context.Background(), "instanceID"),
+ projectID: "",
+ resourceOwner: "org1",
+ oidcApp: &domain.OIDCApp{AppName: "app"},
+ },
+ res: res{
+ err: zerrors.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "invalid app, invalid argument error",
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ args: args{
+ ctx: authz.WithInstanceID(context.Background(), "instanceID"),
+ projectID: "project1",
+ resourceOwner: "org1",
+ oidcApp: &domain.OIDCApp{AppName: ""},
+ },
+ res: res{
+ err: zerrors.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "project not existing, precondition failed error",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: authz.WithInstanceID(context.Background(), "instanceID"),
+ projectID: "project1",
+ resourceOwner: "org1",
+ oidcApp: &domain.OIDCApp{
+ AppName: "app",
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ RedirectUris: []string{"https://client.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ },
+ },
+ res: res{
+ err: zerrors.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "confidential client (client_secret_basic), secret returned, no permission required",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ project.NewProjectAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ "project", true, true, true,
+ domain.PrivateLabelingSettingUnspecified),
+ ),
+ ),
+ expectFilter(),
+ expectPush(
+ project.NewApplicationAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ "app1",
+ "app (app1)",
+ ),
+ project.NewOIDCConfigAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ domain.OIDCVersionV1,
+ "app1",
+ "client1",
+ "secret",
+ []string{"https://client.example.com/callback"},
+ []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ domain.OIDCApplicationTypeWeb,
+ domain.OIDCAuthMethodTypeBasic,
+ []string{},
+ false,
+ domain.OIDCTokenTypeBearer,
+ false,
+ false,
+ false,
+ 0,
+ []string{},
+ false,
+ "",
+ domain.LoginVersionUnspecified,
+ "",
+ ),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
+ },
+ args: args{
+ ctx: authz.WithInstanceID(context.Background(), "instanceID"),
+ projectID: "project1",
+ resourceOwner: "org1",
+ oidcApp: &domain.OIDCApp{
+ AppName: "app",
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ RedirectUris: []string{"https://client.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ res: res{
+ want: &domain.OIDCApp{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "project1",
+ ResourceOwner: "org1",
+ },
+ AppID: "app1",
+ AppName: "app (app1)",
+ ClientID: "client1",
+ ClientSecretString: "secret",
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ RedirectUris: []string{"https://client.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ DevMode: gu.Ptr(false),
+ AccessTokenRoleAssertion: gu.Ptr(false),
+ IDTokenRoleAssertion: gu.Ptr(false),
+ IDTokenUserinfoAssertion: gu.Ptr(false),
+ ClockSkew: gu.Ptr(time.Duration(0)),
+ SkipNativeAppSuccessPage: gu.Ptr(false),
+ BackChannelLogoutURI: gu.Ptr(""),
+ LoginVersion: gu.Ptr(domain.LoginVersionUnspecified),
+ LoginBaseURI: gu.Ptr(""),
+ State: domain.AppStateActive,
+ Compliance: &domain.Compliance{},
+ },
+ },
+ },
+ {
+ name: "public client (none), no secret returned, no permission required",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ project.NewProjectAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ "project", true, true, true,
+ domain.PrivateLabelingSettingUnspecified),
+ ),
+ ),
+ expectFilter(),
+ expectPush(
+ project.NewApplicationAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ "app1",
+ "DCR Client app1",
+ ),
+ project.NewOIDCConfigAddedEvent(context.Background(),
+ &project.NewAggregate("project1", "org1").Aggregate,
+ domain.OIDCVersionV1,
+ "app1",
+ "client1",
+ "",
+ []string{"https://client.example.com/callback"},
+ []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ domain.OIDCApplicationTypeWeb,
+ domain.OIDCAuthMethodTypeNone,
+ []string{},
+ false,
+ domain.OIDCTokenTypeBearer,
+ false,
+ false,
+ false,
+ 0,
+ []string{},
+ false,
+ "",
+ domain.LoginVersionUnspecified,
+ "",
+ ),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"),
+ },
+ args: args{
+ ctx: authz.WithInstanceID(context.Background(), "instanceID"),
+ projectID: "project1",
+ resourceOwner: "org1",
+ oidcApp: &domain.OIDCApp{
+ AppName: "",
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ RedirectUris: []string{"https://client.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ },
+ },
+ res: res{
+ want: &domain.OIDCApp{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "project1",
+ ResourceOwner: "org1",
+ },
+ AppID: "app1",
+ AppName: "DCR Client app1",
+ ClientID: "client1",
+ AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeNone),
+ OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
+ RedirectUris: []string{"https://client.example.com/callback"},
+ ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
+ GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode},
+ ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb),
+ AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
+ DevMode: gu.Ptr(false),
+ AccessTokenRoleAssertion: gu.Ptr(false),
+ IDTokenRoleAssertion: gu.Ptr(false),
+ IDTokenUserinfoAssertion: gu.Ptr(false),
+ ClockSkew: gu.Ptr(time.Duration(0)),
+ SkipNativeAppSuccessPage: gu.Ptr(false),
+ BackChannelLogoutURI: gu.Ptr(""),
+ LoginVersion: gu.Ptr(domain.LoginVersionUnspecified),
+ LoginBaseURI: gu.Ptr(""),
+ State: domain.AppStateActive,
+ Compliance: &domain.Compliance{},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ c := &Commands{
+ eventstore: tt.fields.eventstore(t),
+ idGenerator: tt.fields.idGenerator,
+ newHashedSecret: mockHashedSecret("secret"),
+ defaultSecretGenerators: &SecretGenerators{
+ ClientSecret: emptyConfig,
+ },
+ // Dynamic client registration must NOT depend on app.write: deny all
+ // permissions and assert the client is still created.
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ ipLookupFunction: func(_ string) ([]net.IP, error) {
+ return []net.IP{net.ParseIP("1.2.3.4")}, nil
+ },
+ }
+ c.setMilestonesCompletedForTest("instanceID")
+ got, err := c.AddDynamicOIDCClient(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner, tt.args.oidcApp)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
+
+func Test_dcrProjectWriteModel_Reduce(t *testing.T) {
+ t.Parallel()
+
+ aggregate := func(projectID string) *eventstore.Aggregate {
+ return &project.NewAggregate(projectID, "org1").Aggregate
+ }
+ // The write model type-switches on the repository events, so feed it the typed events
+ // directly rather than the raw ones eventFromEventPusher builds for the mock eventstore.
+ added := func(projectID, name string) eventstore.Event {
+ return project.NewProjectAddedEvent(
+ context.Background(), aggregate(projectID), name,
+ false, false, false, domain.PrivateLabelingSettingUnspecified,
+ )
+ }
+ renamed := func(projectID, oldName, newName string) eventstore.Event {
+ return project.NewProjectChangeEvent(
+ context.Background(), aggregate(projectID), oldName,
+ []project.ProjectChanges{project.ChangeName(newName)},
+ )
+ }
+ removed := func(projectID string) eventstore.Event {
+ return project.NewProjectRemovedEvent(
+ context.Background(), aggregate(projectID), DCRProjectName, nil,
+ )
+ }
+
+ tests := []struct {
+ name string
+ events []eventstore.Event
+ want string
+ }{
+ {
+ name: "no events, no project",
+ events: nil,
+ want: "",
+ },
+ {
+ name: "dcr project added",
+ events: []eventstore.Event{added("p1", DCRProjectName)},
+ want: "p1",
+ },
+ {
+ name: "unrelated project ignored",
+ events: []eventstore.Event{added("p1", "Some Other Project")},
+ want: "",
+ },
+ {
+ // Without reducing ProjectChanged the write model would still return p1, while
+ // the name-based projection query no longer finds it.
+ name: "renamed away from the dcr name is dropped",
+ events: []eventstore.Event{added("p1", DCRProjectName), renamed("p1", DCRProjectName, "Renamed")},
+ want: "",
+ },
+ {
+ name: "renamed onto the dcr name is picked up",
+ events: []eventstore.Event{added("p1", "Some Other Project"), renamed("p1", "Some Other Project", DCRProjectName)},
+ want: "p1",
+ },
+ {
+ name: "rename of an unrelated project does not clear the dcr project",
+ events: []eventstore.Event{
+ added("p1", DCRProjectName),
+ added("p2", "Some Other Project"),
+ renamed("p2", "Some Other Project", "Still Something Else"),
+ },
+ want: "p1",
+ },
+ {
+ name: "removed dcr project is dropped",
+ events: []eventstore.Event{added("p1", DCRProjectName), removed("p1")},
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ wm := newDCRProjectWriteModel("org1")
+ wm.AppendEvents(tt.events...)
+ require.NoError(t, wm.Reduce())
+ assert.Equal(t, tt.want, wm.projectID)
+ })
+ }
+}
diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go
index 00057a54e..5194ad2c2 100644
--- a/internal/domain/application_oidc.go
+++ b/internal/domain/application_oidc.go
@@ -379,6 +379,15 @@ func containsCustom(uris []string) bool {
return false
}
+// OIDCRedirectURIsRequireNative reports whether the given redirect URIs can only be
+// compliant for a native application, because they use a custom scheme. It lets a caller
+// that has to infer the application type (see the dynamic client registration endpoint)
+// stay in agreement with CheckRedirectUrisCode and CheckRedirectUrisImplicitAndCode, which
+// reserve custom schemes for native applications.
+func OIDCRedirectURIsRequireNative(redirectURIs []string) bool {
+ return containsCustom(redirectURIs)
+}
+
// onlyLocalhostIsHttp returns true if:
//
// - input string slice is empty
diff --git a/internal/domain/permission.go b/internal/domain/permission.go
index 227f67c14..f4f1b7ed5 100644
--- a/internal/domain/permission.go
+++ b/internal/domain/permission.go
@@ -27,62 +27,63 @@ func (p *Permissions) appendPermission(ctxID, permission string) {
type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error)
const (
- PermissionUserWrite = "user.write"
- PermissionUserRead = "user.read"
- PermissionUserDelete = "user.delete"
- PermissionUserDeleteSelf = "user.self.delete"
- PermissionUserCredentialWrite = "user.credential.write"
- PermissionSessionWrite = "session.write"
- PermissionSessionRead = "session.read"
- PermissionSessionLink = "session.link"
- PermissionSessionDelete = "session.delete"
- PermissionOrgRead = "org.read"
- PermissionOrganizationWrite = "org.write"
- PermissionOrganizationDelete = "org.delete"
- PermissionIDPRead = "iam.idp.read"
- PermissionOrgIDPRead = "org.idp.read"
- PermissionProjectCreate = "project.create"
- PermissionProjectWrite = "project.write"
- PermissionProjectRead = "project.read"
- PermissionProjectDelete = "project.delete"
- PermissionProjectGrantWrite = "project.grant.write"
- PermissionProjectGrantRead = "project.grant.read"
- PermissionProjectGrantDelete = "project.grant.delete"
- PermissionProjectRoleWrite = "project.role.write"
- PermissionProjectRoleRead = "project.role.read"
- PermissionProjectRoleDelete = "project.role.delete"
- PermissionProjectAppWrite = "project.app.write"
- PermissionProjectAppDelete = "project.app.delete"
- PermissionProjectAppRead = "project.app.read"
- PermissionInstanceMemberWrite = "iam.member.write"
- PermissionInstanceMemberDelete = "iam.member.delete"
- PermissionInstanceMemberRead = "iam.member.read"
- PermissionOrgMemberWrite = "org.member.write"
- PermissionOrgMemberDelete = "org.member.delete"
- PermissionOrgMemberRead = "org.member.read"
- PermissionProjectMemberWrite = "project.member.write"
- PermissionProjectMemberDelete = "project.member.delete"
- PermissionProjectMemberRead = "project.member.read"
- PermissionProjectGrantMemberWrite = "project.grant.member.write"
- PermissionProjectGrantMemberDelete = "project.grant.member.delete"
- PermissionProjectGrantMemberRead = "project.grant.member.read"
- PermissionUserGrantWrite = "user.grant.write"
- PermissionUserGrantRead = "user.grant.read"
- PermissionUserGrantDelete = "user.grant.delete"
- PermissionIAMPolicyWrite = "iam.policy.write"
- PermissionIAMPolicyDelete = "iam.policy.delete"
- PermissionPolicyRead = "policy.read"
- PermissionInstanceRead = "iam.read"
- PermissionInstanceWrite = "iam.write"
- PermissionSystemInstanceRead = "system.instance.read"
- PermissionSystemInstanceWrite = "system.instance.write"
- PermissionGroupCreate = "group.create"
- PermissionGroupWrite = "group.write"
- PermissionGroupRead = "group.read"
- PermissionGroupDelete = "group.delete"
- PermissionGroupUserWrite = "group.user.write"
- PermissionGroupUserRead = "group.user.read"
- PermissionGroupUserDelete = "group.user.delete"
+ PermissionUserWrite = "user.write"
+ PermissionUserRead = "user.read"
+ PermissionUserDelete = "user.delete"
+ PermissionUserDeleteSelf = "user.self.delete"
+ PermissionUserCredentialWrite = "user.credential.write"
+ PermissionSessionWrite = "session.write"
+ PermissionSessionRead = "session.read"
+ PermissionSessionLink = "session.link"
+ PermissionSessionDelete = "session.delete"
+ PermissionOrgRead = "org.read"
+ PermissionOrganizationWrite = "org.write"
+ PermissionOrganizationDelete = "org.delete"
+ PermissionIDPRead = "iam.idp.read"
+ PermissionOrgIDPRead = "org.idp.read"
+ PermissionProjectCreate = "project.create"
+ PermissionProjectWrite = "project.write"
+ PermissionProjectRead = "project.read"
+ PermissionProjectDelete = "project.delete"
+ PermissionProjectGrantWrite = "project.grant.write"
+ PermissionProjectGrantRead = "project.grant.read"
+ PermissionProjectGrantDelete = "project.grant.delete"
+ PermissionProjectRoleWrite = "project.role.write"
+ PermissionProjectRoleRead = "project.role.read"
+ PermissionProjectRoleDelete = "project.role.delete"
+ PermissionProjectAppWrite = "project.app.write"
+ PermissionProjectAppRegisterDynamic = "project.app.register_dynamic"
+ PermissionProjectAppDelete = "project.app.delete"
+ PermissionProjectAppRead = "project.app.read"
+ PermissionInstanceMemberWrite = "iam.member.write"
+ PermissionInstanceMemberDelete = "iam.member.delete"
+ PermissionInstanceMemberRead = "iam.member.read"
+ PermissionOrgMemberWrite = "org.member.write"
+ PermissionOrgMemberDelete = "org.member.delete"
+ PermissionOrgMemberRead = "org.member.read"
+ PermissionProjectMemberWrite = "project.member.write"
+ PermissionProjectMemberDelete = "project.member.delete"
+ PermissionProjectMemberRead = "project.member.read"
+ PermissionProjectGrantMemberWrite = "project.grant.member.write"
+ PermissionProjectGrantMemberDelete = "project.grant.member.delete"
+ PermissionProjectGrantMemberRead = "project.grant.member.read"
+ PermissionUserGrantWrite = "user.grant.write"
+ PermissionUserGrantRead = "user.grant.read"
+ PermissionUserGrantDelete = "user.grant.delete"
+ PermissionIAMPolicyWrite = "iam.policy.write"
+ PermissionIAMPolicyDelete = "iam.policy.delete"
+ PermissionPolicyRead = "policy.read"
+ PermissionInstanceRead = "iam.read"
+ PermissionInstanceWrite = "iam.write"
+ PermissionSystemInstanceRead = "system.instance.read"
+ PermissionSystemInstanceWrite = "system.instance.write"
+ PermissionGroupCreate = "group.create"
+ PermissionGroupWrite = "group.write"
+ PermissionGroupRead = "group.read"
+ PermissionGroupDelete = "group.delete"
+ PermissionGroupUserWrite = "group.user.write"
+ PermissionGroupUserRead = "group.user.read"
+ PermissionGroupUserDelete = "group.user.delete"
)
// ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants.
diff --git a/internal/domain/roles.go b/internal/domain/roles.go
index d3f7fc52d..ab2306040 100644
--- a/internal/domain/roles.go
+++ b/internal/domain/roles.go
@@ -7,19 +7,22 @@ import (
)
const (
- IAMRolePrefix = "IAM"
- OrgRolePrefix = "ORG"
- ProjectRolePrefix = "PROJECT"
- ProjectGrantRolePrefix = "PROJECT_GRANT"
- RoleOrgOwner = "ORG_OWNER"
- RoleOrgProjectCreator = "ORG_PROJECT_CREATOR"
- RoleIAMOwner = "IAM_OWNER"
- RoleIAMOwnerViewer = "IAM_OWNER_VIEWER"
- RoleIAMLoginClient = "IAM_LOGIN_CLIENT"
- RoleProjectOwner = "PROJECT_OWNER"
- RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL"
- RoleProjectGrantOwner = "PROJECT_GRANT_OWNER"
- RoleSelfManagementGlobal = "SELF_MANAGEMENT_GLOBAL"
+ IAMRolePrefix = "IAM"
+ OrgRolePrefix = "ORG"
+ ProjectRolePrefix = "PROJECT"
+ ProjectGrantRolePrefix = "PROJECT_GRANT"
+ RoleOrgOwner = "ORG_OWNER"
+ RoleOrgProjectCreator = "ORG_PROJECT_CREATOR"
+ // RoleOrgDynamicClientRegistrar is a least-privilege role for service users that only
+ // need to register OAuth 2.0 clients through the RFC 7591 endpoint.
+ RoleOrgDynamicClientRegistrar = "ORG_DYNAMIC_CLIENT_REGISTRAR"
+ RoleIAMOwner = "IAM_OWNER"
+ RoleIAMOwnerViewer = "IAM_OWNER_VIEWER"
+ RoleIAMLoginClient = "IAM_LOGIN_CLIENT"
+ RoleProjectOwner = "PROJECT_OWNER"
+ RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL"
+ RoleProjectGrantOwner = "PROJECT_GRANT_OWNER"
+ RoleSelfManagementGlobal = "SELF_MANAGEMENT_GLOBAL"
)
func CheckForInvalidRoles(roles []string, rolePrefix string, validRoles []authz.RoleMapping) []string {
diff --git a/internal/query/instance.go b/internal/query/instance.go
index 0614527e4..d3fdd4d26 100644
--- a/internal/query/instance.go
+++ b/internal/query/instance.go
@@ -467,6 +467,7 @@ type authzInstance struct {
DefaultOrgID string `json:"default_org_id,omitempty"`
CSP csp `json:"csp,omitempty"`
Impersonation bool `json:"impersonation,omitempty"`
+ DCR dcr `json:"dcr,omitempty"`
IsBlocked *bool `json:"is_blocked,omitempty"`
LogRetention *time.Duration `json:"log_retention,omitempty"`
Feature feature.Features `json:"feature,omitempty"`
@@ -481,6 +482,12 @@ type csp struct {
AllowedOrigins database.TextArray[string] `json:"allowed_origins,omitempty"`
}
+// dcr holds the instance's OAuth 2.0 Dynamic Client Registration (RFC 7591) settings.
+type dcr struct {
+ Enabled bool `json:"enabled,omitempty"`
+ AllowUnauthenticated bool `json:"allow_unauthenticated,omitempty"`
+}
+
func (i *authzInstance) InstanceID() string {
return i.ID
}
@@ -520,6 +527,14 @@ func (i *authzInstance) EnableImpersonation() bool {
return i.Impersonation
}
+func (i *authzInstance) EnableDynamicClientRegistration() bool {
+ return i.DCR.Enabled
+}
+
+func (i *authzInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
+ return i.DCR.Enabled && i.DCR.AllowUnauthenticated
+}
+
func (i *authzInstance) Block() *bool {
return i.IsBlocked
}
@@ -568,6 +583,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
lang string
enableIframeEmbedding sql.NullBool
enableImpersonation sql.NullBool
+ enableDCR sql.NullBool
+ allowUnauthDCR sql.NullBool
auditLogRetention database.NullDuration
block sql.NullBool
features []byte
@@ -584,6 +601,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
&enableIframeEmbedding,
&instance.CSP.AllowedOrigins,
&enableImpersonation,
+ &enableDCR,
+ &allowUnauthDCR,
&auditLogRetention,
&block,
&features,
@@ -607,6 +626,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
}
instance.CSP.EnableIframeEmbedding = enableIframeEmbedding.Bool
instance.Impersonation = enableImpersonation.Bool
+ instance.DCR.Enabled = enableDCR.Bool
+ instance.DCR.AllowUnauthenticated = allowUnauthDCR.Bool
if len(features) > 0 {
if err = json.Unmarshal(features, &instance.Feature); err != nil {
return zerrors.ThrowInternal(err, "QUERY-Po8ki", "Errors.Internal")
diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql
index aaf1052e6..c0efe3383 100644
--- a/internal/query/instance_by_domain.sql
+++ b/internal/query/instance_by_domain.sql
@@ -68,6 +68,8 @@ select
s.enable_iframe_embedding,
s.origins,
s.enable_impersonation,
+ s.enable_dynamic_client_registration,
+ s.allow_unauthenticated_dynamic_client_registration,
l.audit_log_retention,
l.block,
f.features,
@@ -77,7 +79,7 @@ select
r.allowed_languages
from domain d
join projections.instances i on i.id = d.instance_id
-left join projections.security_policies2 s on i.id = s.instance_id
+left join projections.security_policies3 s on i.id = s.instance_id
left join projections.limits l on i.id = l.instance_id
left join features f on i.id = f.instance_id
left join external_domains ed on i.id = ed.instance_id
diff --git a/internal/query/instance_by_id.sql b/internal/query/instance_by_id.sql
index c6e8a0a12..44770983a 100644
--- a/internal/query/instance_by_id.sql
+++ b/internal/query/instance_by_id.sql
@@ -58,6 +58,8 @@ select
s.enable_iframe_embedding,
s.origins,
s.enable_impersonation,
+ s.enable_dynamic_client_registration,
+ s.allow_unauthenticated_dynamic_client_registration,
l.audit_log_retention,
l.block,
f.features,
@@ -66,7 +68,7 @@ select
et.execution_targets,
r.allowed_languages
from projections.instances i
-left join projections.security_policies2 s on i.id = s.instance_id
+left join projections.security_policies3 s on i.id = s.instance_id
left join projections.limits l on i.id = l.instance_id
left join features f on i.id = f.instance_id
left join external_domains ed on i.id = ed.instance_id
diff --git a/internal/query/instance_test.go b/internal/query/instance_test.go
index 724689b8d..b015a5d9c 100644
--- a/internal/query/instance_test.go
+++ b/internal/query/instance_test.go
@@ -10,6 +10,7 @@ import (
"testing"
sq "github.com/Masterminds/squirrel"
+ "github.com/stretchr/testify/assert"
"golang.org/x/text/language"
)
@@ -307,3 +308,29 @@ func Test_InstancePrepares(t *testing.T) {
})
}
}
+
+// Mirrors Test_instance_dynamicClientRegistration in the authz package: the query-backed
+// instance must apply the same precedence, so that disabling dynamic client registration
+// closes the endpoint regardless of the open-registration setting.
+func Test_authzInstance_dynamicClientRegistration(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ dcr dcr
+ wantEnabled bool
+ wantAllowUnauthenticated bool
+ }{
+ {name: "disabled", dcr: dcr{}},
+ {name: "disabled but unauthenticated allowed", dcr: dcr{AllowUnauthenticated: true}},
+ {name: "enabled, token mode", dcr: dcr{Enabled: true}, wantEnabled: true},
+ {name: "enabled, open registration", dcr: dcr{Enabled: true, AllowUnauthenticated: true}, wantEnabled: true, wantAllowUnauthenticated: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ i := &authzInstance{DCR: tt.dcr}
+ assert.Equal(t, tt.wantEnabled, i.EnableDynamicClientRegistration())
+ assert.Equal(t, tt.wantAllowUnauthenticated, i.AllowUnauthenticatedDynamicClientRegistration())
+ })
+ }
+}
diff --git a/internal/query/projection/security_policy.go b/internal/query/projection/security_policy.go
index 2de5ace84..19614d734 100644
--- a/internal/query/projection/security_policy.go
+++ b/internal/query/projection/security_policy.go
@@ -11,7 +11,7 @@ import (
)
const (
- SecurityPolicyProjectionTable = "projections.security_policies2"
+ SecurityPolicyProjectionTable = "projections.security_policies3"
SecurityPolicyColumnInstanceID = "instance_id"
SecurityPolicyColumnCreationDate = "creation_date"
SecurityPolicyColumnChangeDate = "change_date"
@@ -19,6 +19,9 @@ const (
SecurityPolicyColumnEnableIframeEmbedding = "enable_iframe_embedding"
SecurityPolicyColumnAllowedOrigins = "origins"
SecurityPolicyColumnEnableImpersonation = "enable_impersonation"
+
+ SecurityPolicyColumnEnableDynamicClientRegistration = "enable_dynamic_client_registration"
+ SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration = "allow_unauthenticated_dynamic_client_registration"
)
type securityPolicyProjection struct{}
@@ -41,6 +44,8 @@ func (*securityPolicyProjection) Init() *old_handler.Check {
handler.NewColumn(SecurityPolicyColumnEnableIframeEmbedding, handler.ColumnTypeBool, handler.Default(false)),
handler.NewColumn(SecurityPolicyColumnAllowedOrigins, handler.ColumnTypeTextArray, handler.Nullable()),
handler.NewColumn(SecurityPolicyColumnEnableImpersonation, handler.ColumnTypeBool, handler.Default(false)),
+ handler.NewColumn(SecurityPolicyColumnEnableDynamicClientRegistration, handler.ColumnTypeBool, handler.Default(false)),
+ handler.NewColumn(SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration, handler.ColumnTypeBool, handler.Default(false)),
},
handler.NewPrimaryKey(SecurityPolicyColumnInstanceID),
),
@@ -87,6 +92,12 @@ func (p *securityPolicyProjection) reduceSecurityPolicySet(event eventstore.Even
if e.EnableImpersonation != nil {
changes = append(changes, handler.NewCol(SecurityPolicyColumnEnableImpersonation, e.EnableImpersonation))
}
+ if e.EnableDynamicClientRegistration != nil {
+ changes = append(changes, handler.NewCol(SecurityPolicyColumnEnableDynamicClientRegistration, e.EnableDynamicClientRegistration))
+ }
+ if e.AllowUnauthenticatedDynamicClientRegistration != nil {
+ changes = append(changes, handler.NewCol(SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration, e.AllowUnauthenticatedDynamicClientRegistration))
+ }
return handler.NewUpsertStatement(
e,
[]handler.Column{
diff --git a/internal/query/projection/settings_relational.go b/internal/query/projection/settings_relational.go
index 757d875be..6f95e7258 100644
--- a/internal/query/projection/settings_relational.go
+++ b/internal/query/projection/settings_relational.go
@@ -1385,6 +1385,9 @@ func (p *relationalTablesProjection) reduceSecurityPolicySet(event eventstore.Ev
EnableIframeEmbedding: policyEvent.EnableIframeEmbedding,
AllowedOrigins: allowedOrigins,
EnableImpersonation: policyEvent.EnableImpersonation,
+
+ EnableDynamicClientRegistration: policyEvent.EnableDynamicClientRegistration,
+ AllowUnauthenticatedDynamicClientRegistration: policyEvent.AllowUnauthenticatedDynamicClientRegistration,
},
}
return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings)
diff --git a/internal/query/security_policy.go b/internal/query/security_policy.go
index 5a2450258..5c3f2221c 100644
--- a/internal/query/security_policy.go
+++ b/internal/query/security_policy.go
@@ -47,6 +47,14 @@ var (
name: projection.SecurityPolicyColumnEnableImpersonation,
table: securityPolicyTable,
}
+ SecurityPolicyColumnEnableDynamicClientRegistration = Column{
+ name: projection.SecurityPolicyColumnEnableDynamicClientRegistration,
+ table: securityPolicyTable,
+ }
+ SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration = Column{
+ name: projection.SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration,
+ table: securityPolicyTable,
+ }
)
type SecurityPolicy struct {
@@ -59,6 +67,9 @@ type SecurityPolicy struct {
EnableIframeEmbedding bool
AllowedOrigins database.TextArray[string]
EnableImpersonation bool
+
+ EnableDynamicClientRegistration bool
+ AllowUnauthenticatedDynamicClientRegistration bool
}
func (q *Queries) SecurityPolicy(ctx context.Context) (policy *SecurityPolicy, err error) {
@@ -86,7 +97,9 @@ func prepareSecurityPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*SecurityPo
SecurityPolicyColumnSequence.identifier(),
SecurityPolicyColumnEnableIframeEmbedding.identifier(),
SecurityPolicyColumnAllowedOrigins.identifier(),
- SecurityPolicyColumnEnableImpersonation.identifier()).
+ SecurityPolicyColumnEnableImpersonation.identifier(),
+ SecurityPolicyColumnEnableDynamicClientRegistration.identifier(),
+ SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration.identifier()).
From(securityPolicyTable.identifier()).
PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*SecurityPolicy, error) {
@@ -100,6 +113,8 @@ func prepareSecurityPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*SecurityPo
&securityPolicy.EnableIframeEmbedding,
&securityPolicy.AllowedOrigins,
&securityPolicy.EnableImpersonation,
+ &securityPolicy.EnableDynamicClientRegistration,
+ &securityPolicy.AllowUnauthenticatedDynamicClientRegistration,
)
if err != nil && !errors.Is(err, sql.ErrNoRows) { // ignore not found errors
return nil, zerrors.ThrowInternal(err, "QUERY-Dfrt2", "Errors.Internal")
diff --git a/internal/repository/instance/policy_security.go b/internal/repository/instance/policy_security.go
index c3e999cba..b7d1e64a1 100644
--- a/internal/repository/instance/policy_security.go
+++ b/internal/repository/instance/policy_security.go
@@ -21,6 +21,13 @@ type SecurityPolicySetEvent struct {
EnableIframeEmbedding *bool `json:"enable_iframe_embedding,omitempty"`
AllowedOrigins *[]string `json:"allowedOrigins,omitempty"`
EnableImpersonation *bool `json:"enable_impersonation,omitempty"`
+
+ // EnableDynamicClientRegistration serves and advertises the OAuth 2.0 Dynamic Client
+ // Registration endpoint (RFC 7591).
+ EnableDynamicClientRegistration *bool `json:"enable_dynamic_client_registration,omitempty"`
+ // AllowUnauthenticatedDynamicClientRegistration additionally allows registration
+ // without an access token. It only has an effect if EnableDynamicClientRegistration.
+ AllowUnauthenticatedDynamicClientRegistration *bool `json:"allow_unauthenticated_dynamic_client_registration,omitempty"`
}
func NewSecurityPolicySetEvent(
@@ -67,6 +74,18 @@ func ChangeSecurityPolicyEnableImpersonation(enabled bool) func(event *SecurityP
}
}
+func ChangeSecurityPolicyEnableDynamicClientRegistration(enabled bool) func(event *SecurityPolicySetEvent) {
+ return func(e *SecurityPolicySetEvent) {
+ e.EnableDynamicClientRegistration = &enabled
+ }
+}
+
+func ChangeSecurityPolicyAllowUnauthenticatedDynamicClientRegistration(allow bool) func(event *SecurityPolicySetEvent) {
+ return func(e *SecurityPolicySetEvent) {
+ e.AllowUnauthenticatedDynamicClientRegistration = &allow
+ }
+}
+
func (e *SecurityPolicySetEvent) Payload() interface{} {
return e
}
diff --git a/proto/zitadel/settings/v2/security_settings.proto b/proto/zitadel/settings/v2/security_settings.proto
index 330ac535b..ed399d757 100644
--- a/proto/zitadel/settings/v2/security_settings.proto
+++ b/proto/zitadel/settings/v2/security_settings.proto
@@ -14,6 +14,26 @@ message SecuritySettings {
// If enabled, users are allowed to impersonate other users.
// The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well".
bool enable_impersonation = 2;
+
+ // DynamicClientRegistrationSettings defines if OAuth 2.0 clients may register
+ // themselves at runtime (RFC 7591) and whether they may do so without a token.
+ DynamicClientRegistrationSettings dynamic_client_registration = 3;
+}
+
+message DynamicClientRegistrationSettings {
+ // Enabled states if the OAuth 2.0 Dynamic Client Registration endpoint (RFC 7591) is
+ // served and advertised as `registration_endpoint` in the discovery document.
+ // When disabled, the endpoint responds with 404 and is absent from discovery.
+ bool enabled = 1;
+
+ // AllowUnauthenticated states if clients may register without a bearer token, as
+ // required by the Model Context Protocol (MCP) flow. Such clients are homed in the
+ // instance's default organization. It only has an effect if enabled is true.
+ //
+ // When false, registration requires an access token whose user holds the
+ // `project.app.register_dynamic` permission in the token's organization, and the
+ // client is homed in that organization.
+ bool allow_unauthenticated = 2;
}
message EmbeddedIframeSettings{
diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto
index 45f7a3410..05d2bcd49 100644
--- a/proto/zitadel/settings/v2/settings_service.proto
+++ b/proto/zitadel/settings/v2/settings_service.proto
@@ -806,6 +806,10 @@ message SetSecuritySettingsRequest{
// If enabled, users are allowed to impersonate other users.
// The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well".
bool enable_impersonation = 2;
+
+ // DynamicClientRegistrationSettings defines if OAuth 2.0 clients may register
+ // themselves at runtime (RFC 7591) and whether they may do so without a token.
+ DynamicClientRegistrationSettings dynamic_client_registration = 3;
}
message SetSecuritySettingsResponse{