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{