feat(oidc): add RFC 7591 dynamic client registration (#12313)

# Which Problems Are Solved

ZITADEL cannot currently be used as an OAuth 2.0 Authorization Server by
clients that need to register themselves at runtime:

- There is no [OAuth 2.0 Dynamic Client Registration (RFC
7591)](https://datatracker.ietf.org/doc/html/rfc7591) endpoint, and no
`registration_endpoint` is advertised in the discovery document.
- This blocks [Model Context Protocol
(MCP)](https://modelcontextprotocol.io) clients (Claude Desktop,
claude.ai, Cursor, the MCP SDKs), whose OAuth 2.1 authorization profile
expects an Authorization Server that supports dynamic client
registration. They self-register before any user context exists and only
then start the authorization code + PKCE flow.

# How the Problems Are Solved

## Control plane: instance security settings

No feature flag and no runtime config. A
`DynamicClientRegistrationSettings` message is nested under the instance
`SecuritySettings` in `settings/v2`, next to `embedded_iframe` and
`enable_impersonation`:

| `enabled` | `allow_unauthenticated` | Behaviour |
| --- | --- | --- |
| `false` (default) | — | `POST /oauth/v2/register` returns `404`,
`registration_endpoint` absent from discovery |
| `true` | `false` (default) | Token-gated registration, client homed in
the token's organization |
| `true` | `true` | Open registration for MCP hosts, client homed in the
instance default organization |

Both values are carried on `authz.Instance` and loaded with the
instance, exactly like `EnableImpersonation`, so the discovery document
and the endpoint read them per request without an extra query.
`allow_unauthenticated` implies `enabled` in both implementations, so a
single setting closes the endpoint.

Adding the two columns bumps the projection table to
`projections.security_policies3`, following what the impersonation
setting did for `security_policies2`.

## Authorization: a dedicated permission, not `project.app.write`

- New org-scoped permission **`project.app.register_dynamic`**, granted
by default to `ORG_OWNER`, `IAM_OWNER` and `IAM_ORG_MANAGER` — in both
`InternalAuthZ` and `SystemAuthZ`, mirroring where `project.app.write`
already lives.
- New built-in role **`ORG_DYNAMIC_CLIENT_REGISTRAR`** carrying only
that permission, so a service user can self-register clients without
gaining write access to the organization's existing applications.
- Open mode (`allow_unauthenticated = true`) requires no token and no
permission: that is the MCP self-register path.
- A valid token whose user lacks the permission is answered with **`403
insufficient_scope`** (RFC 6750 §3.1) rather than `401 invalid_token`,
so a caller can tell "your token is bad" from "your token may not do
this". `verifyAccessToken` also reports invalid tokens as permission
denied, so the authorization failure is marked explicitly to keep the
two apart.

The check runs once, at the endpoint, right after the token is verified
and before any state is created, so it also gates the auto-provisioning
of the organization's DCR project. The permission itself lives in the
command layer next to the others
(`Commands.CheckPermissionRegisterDynamicClient`). As the OIDC endpoints
are not behind the authorization interceptor, the caller identity is
derived from the verified token, the way token exchange does for the
impersonating actor.

## Registration itself

- **Registered clients are ordinary OIDC applications.** The endpoint
reuses the existing application-creation events
(`NewApplicationAddedEvent` and `NewOIDCConfigAddedEvent`), so the whole
token, authorization and introspection flow keeps working unchanged.
They are stored in a dedicated, auto-provisioned project named `ZITADEL
DCR` per organization (name lookup), so they do not pollute the
`IAMProject` or other projects.
- Input is validated with the existing `GetOIDCV1Compliance` rules;
`private_key_jwt` and `jwks`/`jwks_uri` are rejected with
`invalid_client_metadata`.
- **Endpoint wiring without forking the library.** `NewServer` calls
`op.RegisterServer` instead of `op.RegisterLegacyServer`, so the new
route can be added through the same `op.WithSetRouter` hook as the
authorize callback. `op.RegisterLegacyServer` appends a middleware after
the caller options, which chi forbids once a route has been registered;
`op.NewIssuerInterceptor` reproduces the issuer middleware it would
otherwise add. The `/oauth/v2` routing prefix is unchanged.

# Additional Changes

- **`application_type` inference for custom-scheme redirects** (last
commit, isolated). `application_type` is an OpenID Connect member; RFC
7591 does not define it, so clients that only implement RFC 7591 omit
it. Native MCP hosts doing so register custom-scheme redirect URIs,
which ZITADEL reserves for native applications, and the OIDC default of
`web` rejected them with `invalid_redirect_uri`. Inference is limited to
custom schemes and to an absent `application_type`, so every request
accepted today keeps its current application type and auth method — an
`http` loopback redirect still yields a web application. Happy to drop
this commit if you would rather keep it out of this PR.
- **Docs**: a Dynamic Client Registration integration guide, a
`registration_endpoint` section on the OpenID Connect endpoints page,
and the new role in the administrators table (plus its description in
the Console translations).

# Additional Context

- Part of #9810. This is **Phase 1 (DCR)**; #12315 adds RFC 7592 and
#12316 adds CIMD, both stacked on this branch and rebased once this
lands.
- Implements the control-plane and authorization model requested in the
review of this PR, itself a refinement of the direction in
https://github.com/zitadel/zitadel/issues/9810#issuecomment-4891176997.
- **Out of scope (deliberately)**: RFC 7592, CIMD, persisting free-form
metadata (`logo_uri`, `contacts`, and similar), and `private_key_jwt` /
`jwks_uri` (rejected cleanly). No new event types.
- Follow-ups explicitly left out, per the review: DCR client lifecycle
and richer audit, consent and custom scopes, JWT / `aud` (RFC 8707), and
RFC 8414 AS metadata (currently absent from both ZITADEL and
`zitadel/oidc`).
This commit is contained in:
jmarette
2026-07-30 14:43:47 +00:00
committed by GitHub
parent adb2974662
commit f34cc45eac
57 changed files with 2651 additions and 87 deletions
@@ -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. 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. 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.
<Callout type="warn">
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.
</Callout>
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 ## 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). **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).
@@ -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.
<Callout type="warn">
Dynamic client registration is disabled by default. Only enable it if you understand the trade-offs described in
[Registration modes](#registration-modes) below.
</Callout>
## 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 <token of an IAM_OWNER>' \
--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.
@@ -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 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 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 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 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 | | 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. | | Project Owner | PROJECT_OWNER | Manage everything within a project. This includes to grant users for the project. |
@@ -3,7 +3,7 @@
// //
// Generated by this command: // 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. // Package domainmock is a generated GoMock package.
@@ -551,6 +551,44 @@ func (c *MockSecuritySettingsRepositorySetCall) DoAndReturn(f func(context.Conte
return c 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. // SetAllowedOrigins mocks base method.
func (m *MockSecuritySettingsRepository) SetAllowedOrigins(values []string) json.JsonUpdate { func (m *MockSecuritySettingsRepository) SetAllowedOrigins(values []string) json.JsonUpdate {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -589,6 +627,44 @@ func (c *MockSecuritySettingsRepositorySetAllowedOriginsCall) DoAndReturn(f func
return c 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. // SetEnableIframeEmbedding mocks base method.
func (m *MockSecuritySettingsRepository) SetEnableIframeEmbedding(value bool) json.JsonUpdate { func (m *MockSecuritySettingsRepository) SetEnableIframeEmbedding(value bool) json.JsonUpdate {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+5
View File
@@ -324,6 +324,8 @@ type securitySettingsJSONChanges interface {
SetEnableIframeEmbedding(value bool) db_json.JsonUpdate SetEnableIframeEmbedding(value bool) db_json.JsonUpdate
SetAllowedOrigins(values []string) db_json.JsonUpdate SetAllowedOrigins(values []string) db_json.JsonUpdate
SetEnableImpersonation(value bool) db_json.JsonUpdate SetEnableImpersonation(value bool) db_json.JsonUpdate
SetEnableDynamicClientRegistration(value bool) db_json.JsonUpdate
SetAllowUnauthenticatedDynamicClientRegistration(value bool) db_json.JsonUpdate
} }
type SecuritySettings struct { type SecuritySettings struct {
@@ -335,6 +337,9 @@ type SecuritySettingsAttributes struct {
EnableIframeEmbedding *bool `json:"enableIframeEmbedding,omitempty"` EnableIframeEmbedding *bool `json:"enableIframeEmbedding,omitempty"`
AllowedOrigins []string `json:"allowedOrigins,omitempty"` AllowedOrigins []string `json:"allowedOrigins,omitempty"`
EnableImpersonation *bool `json:"enableImpersonation,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 //go:generate mockgen -typed -package domainmock -destination ./mock/security_settings.mock.go . SecuritySettingsRepository
@@ -977,6 +977,12 @@ func (s securitySettings) SetSettingFields(value domain.SecuritySettingsAttribut
if value.EnableImpersonation != nil { if value.EnableImpersonation != nil {
changes = append(changes, s.SetEnableImpersonation(*value.EnableImpersonation)) 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...) 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) 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 { func SecuritySettingsRepository() domain.SecuritySettingsRepository {
return &securitySettings{ return &securitySettings{
settings{}, settings{},
@@ -1,7 +1,9 @@
package repository_test package repository_test
import ( import (
"fmt"
"net/url" "net/url"
"strings"
"testing" "testing"
"time" "time"
@@ -5178,3 +5180,26 @@ func TestSecretGeneratorSettingsInviteCodeRoundTrip(t *testing.T) {
require.NotNil(t, list[0].InviteCode) require.NotNil(t, list[0].InviteCode)
assert.EqualExportedValues(t, want.InviteCode, 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")
}
+12
View File
@@ -669,6 +669,10 @@ OIDC:
Path: /oauth/v2/keys # ZITADEL_OIDC_CUSTOMENDPOINTS_KEYS_PATH Path: /oauth/v2/keys # ZITADEL_OIDC_CUSTOMENDPOINTS_KEYS_PATH
DeviceAuth: DeviceAuth:
Path: /oauth/v2/device_authorization # ZITADEL_OIDC_CUSTOMENDPOINTS_DEVICEAUTH_PATH 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: DeviceAuth:
Lifetime: 5m # ZITADEL_OIDC_DEVICEAUTH_LIFETIME Lifetime: 5m # ZITADEL_OIDC_DEVICEAUTH_LIFETIME
PollInterval: 5s # ZITADEL_OIDC_DEVICEAUTH_POLLINTERVAL PollInterval: 5s # ZITADEL_OIDC_DEVICEAUTH_POLLINTERVAL
@@ -1667,6 +1671,7 @@ InternalAuthZ:
- "project.role.delete" - "project.role.delete"
- "project.app.read" - "project.app.read"
- "project.app.write" - "project.app.write"
- "project.app.register_dynamic"
- "project.app.delete" - "project.app.delete"
- "project.grant.read" - "project.grant.read"
- "project.grant.write" - "project.grant.write"
@@ -1782,6 +1787,7 @@ InternalAuthZ:
- "project.role.delete" - "project.role.delete"
- "project.app.read" - "project.app.read"
- "project.app.write" - "project.app.write"
- "project.app.register_dynamic"
- "project.app.delete" - "project.app.delete"
- "project.grant.read" - "project.grant.read"
- "project.grant.write" - "project.grant.write"
@@ -1884,6 +1890,7 @@ InternalAuthZ:
- "project.role.delete" - "project.role.delete"
- "project.app.read" - "project.app.read"
- "project.app.write" - "project.app.write"
- "project.app.register_dynamic"
- "project.grant.read" - "project.grant.read"
- "project.grant.write" - "project.grant.write"
- "project.grant.delete" - "project.grant.delete"
@@ -2039,6 +2046,9 @@ InternalAuthZ:
- "policy.read" - "policy.read"
- "project.read:self" - "project.read:self"
- "project.create" - "project.create"
- Role: "ORG_DYNAMIC_CLIENT_REGISTRAR"
Permissions:
- "project.app.register_dynamic"
- Role: "ORG_ADMIN_IMPERSONATOR" - Role: "ORG_ADMIN_IMPERSONATOR"
Permissions: Permissions:
- "admin.impersonation" - "admin.impersonation"
@@ -2260,6 +2270,7 @@ SystemAuthZ:
- "project.role.delete" - "project.role.delete"
- "project.app.read" - "project.app.read"
- "project.app.write" - "project.app.write"
- "project.app.register_dynamic"
- "project.app.delete" - "project.app.delete"
- "project.grant.read" - "project.grant.read"
- "project.grant.write" - "project.grant.write"
@@ -2375,6 +2386,7 @@ SystemAuthZ:
- "project.role.delete" - "project.role.delete"
- "project.app.read" - "project.app.read"
- "project.app.write" - "project.app.write"
- "project.app.register_dynamic"
- "project.app.delete" - "project.app.delete"
- "project.grant.read" - "project.grant.read"
- "project.grant.write" - "project.grant.write"
+1
View File
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Има разрешение за управление на ролеви назначения", "ORG_USER_PERMISSION_EDITOR": "Има разрешение за управление на ролеви назначения",
"ORG_PROJECT_PERMISSION_EDITOR": "Има разрешение за управление на грантове по проекти", "ORG_PROJECT_PERMISSION_EDITOR": "Има разрешение за управление на грантове по проекти",
"ORG_PROJECT_CREATOR": "Има разрешение да създава свои собствени проекти и основни настройки", "ORG_PROJECT_CREATOR": "Има разрешение да създава свои собствени проекти и основни настройки",
"ORG_DYNAMIC_CLIENT_REGISTRAR": "Има разрешение за динамично регистриране на OAuth 2.0 клиенти",
"ORG_ADMIN_IMPERSONATOR": "Има разрешение да се представя за администратор и крайни потребители от организацията", "ORG_ADMIN_IMPERSONATOR": "Има разрешение да се представя за администратор и крайни потребители от организацията",
"ORG_END_USER_IMPERSONATOR": "Има разрешение да се представя за крайни потребители от организацията", "ORG_END_USER_IMPERSONATOR": "Има разрешение да се представя за крайни потребители от организацията",
"ORG_USER_SELF_MANAGER": "Има разрешение да управлява собствените си потребители", "ORG_USER_SELF_MANAGER": "Има разрешение да управлява собствените си потребители",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Má oprávnění spravovat přiřazení rolí", "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_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_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_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_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", "ORG_USER_SELF_MANAGER": "Má oprávnění spravovat svůj vlastní uživatelský účet",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Verfügt über die Berechtigung zum Verwalten von Rollen-Zuweisungen", "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_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_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_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_END_USER_IMPERSONATOR": "Hat die Berechtigung, sich als Endbenutzer der Organisation auszugeben",
"ORG_USER_SELF_MANAGER": "Hat die Berechtigung, seinen eigenen Benutzer zu verwalten", "ORG_USER_SELF_MANAGER": "Hat die Berechtigung, seinen eigenen Benutzer zu verwalten",
+1
View File
@@ -665,6 +665,7 @@
"ORG_USER_PERMISSION_EDITOR": "Has permission to manage role assignments", "ORG_USER_PERMISSION_EDITOR": "Has permission to manage role assignments",
"ORG_PROJECT_PERMISSION_EDITOR": "Has permission to manage project grants", "ORG_PROJECT_PERMISSION_EDITOR": "Has permission to manage project grants",
"ORG_PROJECT_CREATOR": "Has permission to create his own projects and underlying settings", "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_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_END_USER_IMPERSONATOR": "Has permission to impersonate end users from the organization",
"ORG_USER_SELF_MANAGER": "Has permission to manage their own user", "ORG_USER_SELF_MANAGER": "Has permission to manage their own user",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Tiene permiso para gestionar asignaciones de roles", "ORG_USER_PERMISSION_EDITOR": "Tiene permiso para gestionar asignaciones de roles",
"ORG_PROJECT_PERMISSION_EDITOR": "Tiene permiso para gestionar concesiones de proyecto", "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_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_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_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", "ORG_USER_SELF_MANAGER": "Tiene permiso para gestionar su propio usuario",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "A le droit de gérer les attributions de rôles", "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_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_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_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_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", "ORG_USER_SELF_MANAGER": "A le droit de gérer ses propres utilisateurs",
+1
View File
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Ha l'autorizzazione per gestire le assegnazioni ruoli", "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_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_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_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_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", "ORG_USER_SELF_MANAGER": "Ha il permesso per gestire il proprio account utente",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "ロール割り当てを管理する権限を持ちます", "ORG_USER_PERMISSION_EDITOR": "ロール割り当てを管理する権限を持ちます",
"ORG_PROJECT_PERMISSION_EDITOR": "プロジェクトグラントを管理する権限を持ちます", "ORG_PROJECT_PERMISSION_EDITOR": "プロジェクトグラントを管理する権限を持ちます",
"ORG_PROJECT_CREATOR": "所有するプロジェクトと配下の設定を作成する権限を持ちます", "ORG_PROJECT_CREATOR": "所有するプロジェクトと配下の設定を作成する権限を持ちます",
"ORG_DYNAMIC_CLIENT_REGISTRAR": "OAuth 2.0 クライアントを動的に登録する権限を持ちます",
"ORG_ADMIN_IMPERSONATOR": "組織の管理者およびエンドユーザーになりすます権限がある", "ORG_ADMIN_IMPERSONATOR": "組織の管理者およびエンドユーザーになりすます権限がある",
"ORG_END_USER_IMPERSONATOR": "組織のエンドユーザーになりすます権限がある", "ORG_END_USER_IMPERSONATOR": "組織のエンドユーザーになりすます権限がある",
"ORG_USER_SELF_MANAGER": "自身を管理する権限がある", "ORG_USER_SELF_MANAGER": "自身を管理する権限がある",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Има дозвола за менаџирање на доделените улоги", "ORG_USER_PERMISSION_EDITOR": "Има дозвола за менаџирање на доделените улоги",
"ORG_PROJECT_PERMISSION_EDITOR": "Има дозвола за менаџирање на овластувања на проекти", "ORG_PROJECT_PERMISSION_EDITOR": "Има дозвола за менаџирање на овластувања на проекти",
"ORG_PROJECT_CREATOR": "Има дозвола за креирање на сопствени проекти и нивни подесувања", "ORG_PROJECT_CREATOR": "Има дозвола за креирање на сопствени проекти и нивни подесувања",
"ORG_DYNAMIC_CLIENT_REGISTRAR": "Има дозвола за динамичко регистрирање на OAuth 2.0 клиенти",
"ORG_ADMIN_IMPERSONATOR": "Има дозвола да имитира администратор и крајни корисници од организацијата", "ORG_ADMIN_IMPERSONATOR": "Има дозвола да имитира администратор и крајни корисници од организацијата",
"ORG_END_USER_IMPERSONATOR": "Има дозвола да ги имитира крајните корисници од организацијата", "ORG_END_USER_IMPERSONATOR": "Има дозвола да ги имитира крајните корисници од организацијата",
"ORG_USER_SELF_MANAGER": "Има дозвола за менаџирање на своите корисници", "ORG_USER_SELF_MANAGER": "Има дозвола за менаџирање на своите корисници",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Heeft toestemming om roltoewijzingen te beheren", "ORG_USER_PERMISSION_EDITOR": "Heeft toestemming om roltoewijzingen te beheren",
"ORG_PROJECT_PERMISSION_EDITOR": "Heeft toestemming om projecttoegang 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_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_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_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", "ORG_USER_SELF_MANAGER": "Heeft toestemming om zijn eigen gebruiker te beheren",
+1
View File
@@ -663,6 +663,7 @@
"ORG_USER_PERMISSION_EDITOR": "Ma uprawnienia do zarządzania przypisaniami ról", "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_PERMISSION_EDITOR": "Ma uprawnienie do zarządzania uprawnieniami projektu",
"ORG_PROJECT_CREATOR": "Ma uprawnienie do tworzenia własnych projektów i podstawowych ustawień", "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_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_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", "ORG_USER_SELF_MANAGER": "Ma uprawnienie do zarządzania swoim własnym kontem użytkownika",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Tem permissão para gerenciar atribuições de funções", "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_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_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_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_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", "ORG_USER_SELF_MANAGER": "Tem permissão para gerenciar seu próprio usuário",
+1
View File
@@ -668,6 +668,7 @@
"ORG_USER_PERMISSION_EDITOR": "Имеет разрешение на управление назначениями ролей", "ORG_USER_PERMISSION_EDITOR": "Имеет разрешение на управление назначениями ролей",
"ORG_PROJECT_PERMISSION_EDITOR": "Имеет разрешение на управление допусками проекта", "ORG_PROJECT_PERMISSION_EDITOR": "Имеет разрешение на управление допусками проекта",
"ORG_PROJECT_CREATOR": "Имеет разрешение на создание собственных проектов и базовых настроек", "ORG_PROJECT_CREATOR": "Имеет разрешение на создание собственных проектов и базовых настроек",
"ORG_DYNAMIC_CLIENT_REGISTRAR": "Имеет разрешение на динамическую регистрацию клиентов OAuth 2.0",
"ORG_ADMIN_IMPERSONATOR": "Имеет разрешение выдавать себя за администратора и конечных пользователей организации", "ORG_ADMIN_IMPERSONATOR": "Имеет разрешение выдавать себя за администратора и конечных пользователей организации",
"ORG_END_USER_IMPERSONATOR": "Имеет разрешение выдавать себя за конечных пользователей организации", "ORG_END_USER_IMPERSONATOR": "Имеет разрешение выдавать себя за конечных пользователей организации",
"ORG_USER_SELF_MANAGER": "Имеет разрешение на управление своим собственным пользователем", "ORG_USER_SELF_MANAGER": "Имеет разрешение на управление своим собственным пользователем",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "Rol atamalarını yönetme izni var", "ORG_USER_PERMISSION_EDITOR": "Rol atamalarını yönetme izni var",
"ORG_PROJECT_PERMISSION_EDITOR": "Proje yetkilerini yönetme iznine sahiptir", "ORG_PROJECT_PERMISSION_EDITOR": "Proje yetkilerini yönetme iznine sahiptir",
"ORG_PROJECT_CREATOR": "Kendi projelerini ve altyapı ayarlarını oluşturma 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_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_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", "ORG_USER_SELF_MANAGER": "Kendi kullanıcısını yönetme iznine sahiptir",
+1
View File
@@ -664,6 +664,7 @@
"ORG_USER_PERMISSION_EDITOR": "具有管理角色分配的权限", "ORG_USER_PERMISSION_EDITOR": "具有管理角色分配的权限",
"ORG_PROJECT_PERMISSION_EDITOR": "有权管理项目授权", "ORG_PROJECT_PERMISSION_EDITOR": "有权管理项目授权",
"ORG_PROJECT_CREATOR": "有权创建自己的项目和基础设置", "ORG_PROJECT_CREATOR": "有权创建自己的项目和基础设置",
"ORG_DYNAMIC_CLIENT_REGISTRAR": "有权动态注册 OAuth 2.0 客户端",
"ORG_ADMIN_IMPERSONATOR": "有权模拟组织的管理员和最终用户", "ORG_ADMIN_IMPERSONATOR": "有权模拟组织的管理员和最终用户",
"ORG_END_USER_IMPERSONATOR": "有权模拟组织的最终用户", "ORG_END_USER_IMPERSONATOR": "有权模拟组织的最终用户",
"ORG_USER_SELF_MANAGER": "有权管理自己的用户", "ORG_USER_SELF_MANAGER": "有权管理自己的用户",
+7
View File
@@ -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 { func WithMockProjectID(projectID string) MockContextInstanceOpts {
return func(i *instance) { return func(i *instance) {
i.projectID = projectID i.projectID = projectID
+25 -9
View File
@@ -26,6 +26,12 @@ type Instance interface {
DefaultOrganisationID() string DefaultOrganisationID() string
SecurityPolicyAllowedOrigins() []string SecurityPolicyAllowedOrigins() []string
EnableImpersonation() bool 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 Block() *bool
AuditLogRetention() *time.Duration AuditLogRetention() *time.Duration
Features() feature.Features Features() feature.Features
@@ -41,15 +47,17 @@ type InstanceVerifier interface {
} }
type instance struct { type instance struct {
id string id string
projectID string projectID string
appID string appID string
clientID string clientID string
orgID string orgID string
defaultLanguage language.Tag defaultLanguage language.Tag
allowedLanguages []language.Tag allowedLanguages []language.Tag
features feature.Features features feature.Features
executionTargets target.Router executionTargets target.Router
enableDCR bool
allowUnauthenticatedDCR bool
} }
func (i *instance) Block() *bool { func (i *instance) Block() *bool {
@@ -96,6 +104,14 @@ func (i *instance) EnableImpersonation() bool {
return false 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 { func (i *instance) Features() feature.Features {
return i.features return i.features
} }
+34
View File
@@ -131,6 +131,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false return false
} }
func (m *mockInstance) EnableDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) Features() feature.Features { func (m *mockInstance) Features() feature.Features {
return feature.Features{} return feature.Features{}
} }
@@ -138,3 +146,29 @@ func (m *mockInstance) Features() feature.Features {
func (m *mockInstance) ExecutionRouter() target.Router { func (m *mockInstance) ExecutionRouter() target.Router {
return target.NewRouter(nil) 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())
})
}
}
@@ -26,6 +26,7 @@ var iamRoles = []string{
"ORG_USER_PERMISSION_EDITOR", "ORG_USER_PERMISSION_EDITOR",
"ORG_PROJECT_PERMISSION_EDITOR", "ORG_PROJECT_PERMISSION_EDITOR",
"ORG_PROJECT_CREATOR", "ORG_PROJECT_CREATOR",
"ORG_DYNAMIC_CLIENT_REGISTRAR",
"ORG_USER_SELF_MANAGER", "ORG_USER_SELF_MANAGER",
"ORG_ADMIN_IMPERSONATOR", "ORG_ADMIN_IMPERSONATOR",
"ORG_END_USER_IMPERSONATOR", "ORG_END_USER_IMPERSONATOR",
@@ -309,6 +309,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false return false
} }
func (m *mockInstance) EnableDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) Features() feature.Features { func (m *mockInstance) Features() feature.Features {
return feature.Features{} return feature.Features{}
} }
@@ -246,6 +246,10 @@ func securityPolicyToSettingsPb(policy *query.SecurityPolicy) *settings.Security
AllowedOrigins: policy.AllowedOrigins, AllowedOrigins: policy.AllowedOrigins,
}, },
EnableImpersonation: policy.EnableImpersonation, 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(), EnableIframeEmbedding: req.GetEmbeddedIframe().GetEnabled(),
AllowedOrigins: req.GetEmbeddedIframe().GetAllowedOrigins(), AllowedOrigins: req.GetEmbeddedIframe().GetAllowedOrigins(),
EnableImpersonation: req.GetEnableImpersonation(), EnableImpersonation: req.GetEnableImpersonation(),
EnableDynamicClientRegistration: req.GetDynamicClientRegistration().GetEnabled(),
AllowUnauthenticatedDynamicClientRegistration: req.GetDynamicClientRegistration().GetAllowUnauthenticated(),
} }
} }
@@ -536,20 +536,35 @@ func Test_securityPolicyToSettingsPb(t *testing.T) {
AllowedOrigins: []string{"foo", "bar"}, AllowedOrigins: []string{"foo", "bar"},
}, },
EnableImpersonation: true, EnableImpersonation: true,
DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
Enabled: true,
AllowUnauthenticated: true,
},
} }
got := securityPolicyToSettingsPb(&query.SecurityPolicy{ got := securityPolicyToSettingsPb(&query.SecurityPolicy{
EnableIframeEmbedding: true, EnableIframeEmbedding: true,
AllowedOrigins: []string{"foo", "bar"}, AllowedOrigins: []string{"foo", "bar"},
EnableImpersonation: true, EnableImpersonation: true,
EnableDynamicClientRegistration: true,
AllowUnauthenticatedDynamicClientRegistration: true,
}) })
assert.Equal(t, want, got) 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) { func Test_securitySettingsToCommand(t *testing.T) {
want := &command.SecurityPolicy{ want := &command.SecurityPolicy{
EnableIframeEmbedding: true, EnableIframeEmbedding: true,
AllowedOrigins: []string{"foo", "bar"}, AllowedOrigins: []string{"foo", "bar"},
EnableImpersonation: true, EnableImpersonation: true,
EnableDynamicClientRegistration: true,
AllowUnauthenticatedDynamicClientRegistration: true,
} }
got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{ got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{
EmbeddedIframe: &settings.EmbeddedIframeSettings{ EmbeddedIframe: &settings.EmbeddedIframeSettings{
@@ -557,6 +572,18 @@ func Test_securitySettingsToCommand(t *testing.T) {
AllowedOrigins: []string{"foo", "bar"}, AllowedOrigins: []string{"foo", "bar"},
}, },
EnableImpersonation: true, EnableImpersonation: true,
DynamicClientRegistration: &settings.DynamicClientRegistrationSettings{
Enabled: true,
AllowUnauthenticated: true,
},
}) })
assert.Equal(t, want, got) 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)
}
@@ -397,6 +397,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false return false
} }
func (m *mockInstance) EnableDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) Features() feature.Features { func (m *mockInstance) Features() feature.Features {
return feature.Features{} return feature.Features{}
} }
+559
View File
@@ -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 &registrationError{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
}
@@ -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)
})
}
@@ -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
}
+39 -3
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/oidc/v3/pkg/op"
@@ -64,6 +65,7 @@ type EndpointConfig struct {
EndSession *Endpoint EndSession *Endpoint
Keys *Endpoint Keys *Endpoint
DeviceAuth *Endpoint DeviceAuth *Endpoint
Registration *Endpoint
} }
type Endpoint struct { type Endpoint struct {
@@ -191,10 +193,18 @@ func NewServer(
opCrypto: alg, opCrypto: alg,
assetAPIPrefix: assets.AssetAPI(), assetAPIPrefix: assets.AssetAPI(),
httpClient: httpClient, httpClient: httpClient,
registrationEndpoint: registrationEndpoint(config.CustomEndpoints),
} }
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} 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.WithFallbackLogger(fallbackLogger),
op.WithHTTPMiddleware( op.WithHTTPMiddleware(
middleware.CallDurationHandler, middleware.CallDurationHandler,
@@ -209,11 +219,37 @@ func NewServer(
http_utils.CopyHeadersToContext, http_utils.CopyHeadersToContext,
accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(config.CustomEndpoints)), accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(config.CustomEndpoints)),
middleware.ActivityHandler, 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 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 { func ContextToIssuer(ctx context.Context) string {
return http_utils.DomainContext(ctx).Origin() return http_utils.DomainContext(ctx).Origin()
} }
+10
View File
@@ -45,6 +45,8 @@ type Server struct {
assetAPIPrefix func(ctx context.Context) string assetAPIPrefix func(ctx context.Context) string
httpClient *http.Client httpClient *http.Client
registrationEndpoint *op.Endpoint
} }
func endpoints(endpointConfig *EndpointConfig) op.Endpoints { 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 { func (s *Server) createDiscoveryConfig(ctx context.Context, supportedUILocales oidc.Locales) *oidc.DiscoveryConfiguration {
issuer := op.IssuerFromContext(ctx) 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{ return &oidc.DiscoveryConfiguration{
Issuer: issuer, Issuer: issuer,
RegistrationEndpoint: registrationEndpoint,
AuthorizationEndpoint: s.Endpoints().Authorization.Absolute(issuer), AuthorizationEndpoint: s.Endpoints().Authorization.Absolute(issuer),
TokenEndpoint: s.Endpoints().Token.Absolute(issuer), TokenEndpoint: s.Endpoints().Token.Absolute(issuer),
IntrospectionEndpoint: s.Endpoints().Introspection.Absolute(issuer), IntrospectionEndpoint: s.Endpoints().Introspection.Absolute(issuer),
+41
View File
@@ -8,6 +8,8 @@ import (
"github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/oidc/v3/pkg/op"
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
) )
func TestServer_createDiscoveryConfig(t *testing.T) { 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)
})
}
}
@@ -14,6 +14,9 @@ type SecurityPolicy struct {
EnableIframeEmbedding bool EnableIframeEmbedding bool
AllowedOrigins []string AllowedOrigins []string
EnableImpersonation bool EnableImpersonation bool
EnableDynamicClientRegistration bool
AllowUnauthenticatedDynamicClientRegistration bool
} }
func (c *Commands) SetSecurityPolicy(ctx context.Context, policy *SecurityPolicy) (*domain.ObjectDetails, error) { func (c *Commands) SetSecurityPolicy(ctx context.Context, policy *SecurityPolicy) (*domain.ObjectDetails, error) {
@@ -38,6 +38,12 @@ func (wm *InstanceSecurityPolicyWriteModel) Reduce() error {
if e.EnableImpersonation != nil { if e.EnableImpersonation != nil {
wm.EnableImpersonation = *e.EnableImpersonation wm.EnableImpersonation = *e.EnableImpersonation
} }
if e.EnableDynamicClientRegistration != nil {
wm.EnableDynamicClientRegistration = *e.EnableDynamicClientRegistration
}
if e.AllowUnauthenticatedDynamicClientRegistration != nil {
wm.AllowUnauthenticatedDynamicClientRegistration = *e.AllowUnauthenticatedDynamicClientRegistration
}
} }
} }
return wm.WriteModel.Reduce() return wm.WriteModel.Reduce()
@@ -59,7 +65,7 @@ func (wm *InstanceSecurityPolicyWriteModel) NewSetEvent(
aggregate *eventstore.Aggregate, aggregate *eventstore.Aggregate,
policy *SecurityPolicy, policy *SecurityPolicy,
) (*instance.SecurityPolicySetEvent, error) { ) (*instance.SecurityPolicySetEvent, error) {
changes := make([]instance.SecurityPolicyChanges, 0, 2) changes := make([]instance.SecurityPolicyChanges, 0, 5)
var err error var err error
if wm.EnableIframeEmbedding != policy.EnableIframeEmbedding { if wm.EnableIframeEmbedding != policy.EnableIframeEmbedding {
@@ -71,6 +77,12 @@ func (wm *InstanceSecurityPolicyWriteModel) NewSetEvent(
if wm.EnableImpersonation != policy.EnableImpersonation { if wm.EnableImpersonation != policy.EnableImpersonation {
changes = append(changes, instance.ChangeSecurityPolicyEnableImpersonation(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) changeEvent, err := instance.NewSecurityPolicySetEvent(ctx, aggregate, changes)
if err != nil { if err != nil {
return nil, err return nil, err
+8
View File
@@ -225,6 +225,14 @@ func (m *mockInstance) EnableImpersonation() bool {
return false return false
} }
func (m *mockInstance) EnableDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
return false
}
func (m *mockInstance) Features() feature.Features { func (m *mockInstance) Features() feature.Features {
return feature.Features{} return feature.Features{}
} }
+12
View File
@@ -173,6 +173,18 @@ func (c *Commands) CheckPermissionOrganizationDelete(ctx context.Context, organi
return c.newPermissionCheck(ctx, domain.PermissionOrganizationDelete, org.AggregateType)(organizationID, organizationID) 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 { func (c *Commands) checkPermissionCreateGroup(ctx context.Context, resourceOwner, groupID string) error {
return c.newPermissionCheck(ctx, domain.PermissionGroupCreate, group.AggregateType)(resourceOwner, groupID) return c.newPermissionCheck(ctx, domain.PermissionGroupCreate, group.AggregateType)(resourceOwner, groupID)
} }
@@ -190,6 +190,15 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain
return nil, err 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) projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel)
oidcApp.AppID = appID oidcApp.AppID = appID
@@ -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 + ")"
}
@@ -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)
})
}
}
+9
View File
@@ -379,6 +379,15 @@ func containsCustom(uris []string) bool {
return false 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: // onlyLocalhostIsHttp returns true if:
// //
// - input string slice is empty // - input string slice is empty
+57 -56
View File
@@ -27,62 +27,63 @@ func (p *Permissions) appendPermission(ctxID, permission string) {
type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error) type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error)
const ( const (
PermissionUserWrite = "user.write" PermissionUserWrite = "user.write"
PermissionUserRead = "user.read" PermissionUserRead = "user.read"
PermissionUserDelete = "user.delete" PermissionUserDelete = "user.delete"
PermissionUserDeleteSelf = "user.self.delete" PermissionUserDeleteSelf = "user.self.delete"
PermissionUserCredentialWrite = "user.credential.write" PermissionUserCredentialWrite = "user.credential.write"
PermissionSessionWrite = "session.write" PermissionSessionWrite = "session.write"
PermissionSessionRead = "session.read" PermissionSessionRead = "session.read"
PermissionSessionLink = "session.link" PermissionSessionLink = "session.link"
PermissionSessionDelete = "session.delete" PermissionSessionDelete = "session.delete"
PermissionOrgRead = "org.read" PermissionOrgRead = "org.read"
PermissionOrganizationWrite = "org.write" PermissionOrganizationWrite = "org.write"
PermissionOrganizationDelete = "org.delete" PermissionOrganizationDelete = "org.delete"
PermissionIDPRead = "iam.idp.read" PermissionIDPRead = "iam.idp.read"
PermissionOrgIDPRead = "org.idp.read" PermissionOrgIDPRead = "org.idp.read"
PermissionProjectCreate = "project.create" PermissionProjectCreate = "project.create"
PermissionProjectWrite = "project.write" PermissionProjectWrite = "project.write"
PermissionProjectRead = "project.read" PermissionProjectRead = "project.read"
PermissionProjectDelete = "project.delete" PermissionProjectDelete = "project.delete"
PermissionProjectGrantWrite = "project.grant.write" PermissionProjectGrantWrite = "project.grant.write"
PermissionProjectGrantRead = "project.grant.read" PermissionProjectGrantRead = "project.grant.read"
PermissionProjectGrantDelete = "project.grant.delete" PermissionProjectGrantDelete = "project.grant.delete"
PermissionProjectRoleWrite = "project.role.write" PermissionProjectRoleWrite = "project.role.write"
PermissionProjectRoleRead = "project.role.read" PermissionProjectRoleRead = "project.role.read"
PermissionProjectRoleDelete = "project.role.delete" PermissionProjectRoleDelete = "project.role.delete"
PermissionProjectAppWrite = "project.app.write" PermissionProjectAppWrite = "project.app.write"
PermissionProjectAppDelete = "project.app.delete" PermissionProjectAppRegisterDynamic = "project.app.register_dynamic"
PermissionProjectAppRead = "project.app.read" PermissionProjectAppDelete = "project.app.delete"
PermissionInstanceMemberWrite = "iam.member.write" PermissionProjectAppRead = "project.app.read"
PermissionInstanceMemberDelete = "iam.member.delete" PermissionInstanceMemberWrite = "iam.member.write"
PermissionInstanceMemberRead = "iam.member.read" PermissionInstanceMemberDelete = "iam.member.delete"
PermissionOrgMemberWrite = "org.member.write" PermissionInstanceMemberRead = "iam.member.read"
PermissionOrgMemberDelete = "org.member.delete" PermissionOrgMemberWrite = "org.member.write"
PermissionOrgMemberRead = "org.member.read" PermissionOrgMemberDelete = "org.member.delete"
PermissionProjectMemberWrite = "project.member.write" PermissionOrgMemberRead = "org.member.read"
PermissionProjectMemberDelete = "project.member.delete" PermissionProjectMemberWrite = "project.member.write"
PermissionProjectMemberRead = "project.member.read" PermissionProjectMemberDelete = "project.member.delete"
PermissionProjectGrantMemberWrite = "project.grant.member.write" PermissionProjectMemberRead = "project.member.read"
PermissionProjectGrantMemberDelete = "project.grant.member.delete" PermissionProjectGrantMemberWrite = "project.grant.member.write"
PermissionProjectGrantMemberRead = "project.grant.member.read" PermissionProjectGrantMemberDelete = "project.grant.member.delete"
PermissionUserGrantWrite = "user.grant.write" PermissionProjectGrantMemberRead = "project.grant.member.read"
PermissionUserGrantRead = "user.grant.read" PermissionUserGrantWrite = "user.grant.write"
PermissionUserGrantDelete = "user.grant.delete" PermissionUserGrantRead = "user.grant.read"
PermissionIAMPolicyWrite = "iam.policy.write" PermissionUserGrantDelete = "user.grant.delete"
PermissionIAMPolicyDelete = "iam.policy.delete" PermissionIAMPolicyWrite = "iam.policy.write"
PermissionPolicyRead = "policy.read" PermissionIAMPolicyDelete = "iam.policy.delete"
PermissionInstanceRead = "iam.read" PermissionPolicyRead = "policy.read"
PermissionInstanceWrite = "iam.write" PermissionInstanceRead = "iam.read"
PermissionSystemInstanceRead = "system.instance.read" PermissionInstanceWrite = "iam.write"
PermissionSystemInstanceWrite = "system.instance.write" PermissionSystemInstanceRead = "system.instance.read"
PermissionGroupCreate = "group.create" PermissionSystemInstanceWrite = "system.instance.write"
PermissionGroupWrite = "group.write" PermissionGroupCreate = "group.create"
PermissionGroupRead = "group.read" PermissionGroupWrite = "group.write"
PermissionGroupDelete = "group.delete" PermissionGroupRead = "group.read"
PermissionGroupUserWrite = "group.user.write" PermissionGroupDelete = "group.delete"
PermissionGroupUserRead = "group.user.read" PermissionGroupUserWrite = "group.user.write"
PermissionGroupUserDelete = "group.user.delete" PermissionGroupUserRead = "group.user.read"
PermissionGroupUserDelete = "group.user.delete"
) )
// ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants.
+16 -13
View File
@@ -7,19 +7,22 @@ import (
) )
const ( const (
IAMRolePrefix = "IAM" IAMRolePrefix = "IAM"
OrgRolePrefix = "ORG" OrgRolePrefix = "ORG"
ProjectRolePrefix = "PROJECT" ProjectRolePrefix = "PROJECT"
ProjectGrantRolePrefix = "PROJECT_GRANT" ProjectGrantRolePrefix = "PROJECT_GRANT"
RoleOrgOwner = "ORG_OWNER" RoleOrgOwner = "ORG_OWNER"
RoleOrgProjectCreator = "ORG_PROJECT_CREATOR" RoleOrgProjectCreator = "ORG_PROJECT_CREATOR"
RoleIAMOwner = "IAM_OWNER" // RoleOrgDynamicClientRegistrar is a least-privilege role for service users that only
RoleIAMOwnerViewer = "IAM_OWNER_VIEWER" // need to register OAuth 2.0 clients through the RFC 7591 endpoint.
RoleIAMLoginClient = "IAM_LOGIN_CLIENT" RoleOrgDynamicClientRegistrar = "ORG_DYNAMIC_CLIENT_REGISTRAR"
RoleProjectOwner = "PROJECT_OWNER" RoleIAMOwner = "IAM_OWNER"
RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL" RoleIAMOwnerViewer = "IAM_OWNER_VIEWER"
RoleProjectGrantOwner = "PROJECT_GRANT_OWNER" RoleIAMLoginClient = "IAM_LOGIN_CLIENT"
RoleSelfManagementGlobal = "SELF_MANAGEMENT_GLOBAL" 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 { func CheckForInvalidRoles(roles []string, rolePrefix string, validRoles []authz.RoleMapping) []string {
+21
View File
@@ -467,6 +467,7 @@ type authzInstance struct {
DefaultOrgID string `json:"default_org_id,omitempty"` DefaultOrgID string `json:"default_org_id,omitempty"`
CSP csp `json:"csp,omitempty"` CSP csp `json:"csp,omitempty"`
Impersonation bool `json:"impersonation,omitempty"` Impersonation bool `json:"impersonation,omitempty"`
DCR dcr `json:"dcr,omitempty"`
IsBlocked *bool `json:"is_blocked,omitempty"` IsBlocked *bool `json:"is_blocked,omitempty"`
LogRetention *time.Duration `json:"log_retention,omitempty"` LogRetention *time.Duration `json:"log_retention,omitempty"`
Feature feature.Features `json:"feature,omitempty"` Feature feature.Features `json:"feature,omitempty"`
@@ -481,6 +482,12 @@ type csp struct {
AllowedOrigins database.TextArray[string] `json:"allowed_origins,omitempty"` 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 { func (i *authzInstance) InstanceID() string {
return i.ID return i.ID
} }
@@ -520,6 +527,14 @@ func (i *authzInstance) EnableImpersonation() bool {
return i.Impersonation 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 { func (i *authzInstance) Block() *bool {
return i.IsBlocked return i.IsBlocked
} }
@@ -568,6 +583,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
lang string lang string
enableIframeEmbedding sql.NullBool enableIframeEmbedding sql.NullBool
enableImpersonation sql.NullBool enableImpersonation sql.NullBool
enableDCR sql.NullBool
allowUnauthDCR sql.NullBool
auditLogRetention database.NullDuration auditLogRetention database.NullDuration
block sql.NullBool block sql.NullBool
features []byte features []byte
@@ -584,6 +601,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
&enableIframeEmbedding, &enableIframeEmbedding,
&instance.CSP.AllowedOrigins, &instance.CSP.AllowedOrigins,
&enableImpersonation, &enableImpersonation,
&enableDCR,
&allowUnauthDCR,
&auditLogRetention, &auditLogRetention,
&block, &block,
&features, &features,
@@ -607,6 +626,8 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
} }
instance.CSP.EnableIframeEmbedding = enableIframeEmbedding.Bool instance.CSP.EnableIframeEmbedding = enableIframeEmbedding.Bool
instance.Impersonation = enableImpersonation.Bool instance.Impersonation = enableImpersonation.Bool
instance.DCR.Enabled = enableDCR.Bool
instance.DCR.AllowUnauthenticated = allowUnauthDCR.Bool
if len(features) > 0 { if len(features) > 0 {
if err = json.Unmarshal(features, &instance.Feature); err != nil { if err = json.Unmarshal(features, &instance.Feature); err != nil {
return zerrors.ThrowInternal(err, "QUERY-Po8ki", "Errors.Internal") return zerrors.ThrowInternal(err, "QUERY-Po8ki", "Errors.Internal")
+3 -1
View File
@@ -68,6 +68,8 @@ select
s.enable_iframe_embedding, s.enable_iframe_embedding,
s.origins, s.origins,
s.enable_impersonation, s.enable_impersonation,
s.enable_dynamic_client_registration,
s.allow_unauthenticated_dynamic_client_registration,
l.audit_log_retention, l.audit_log_retention,
l.block, l.block,
f.features, f.features,
@@ -77,7 +79,7 @@ select
r.allowed_languages r.allowed_languages
from domain d from domain d
join projections.instances i on i.id = d.instance_id 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 projections.limits l on i.id = l.instance_id
left join features f on i.id = f.instance_id left join features f on i.id = f.instance_id
left join external_domains ed on i.id = ed.instance_id left join external_domains ed on i.id = ed.instance_id
+3 -1
View File
@@ -58,6 +58,8 @@ select
s.enable_iframe_embedding, s.enable_iframe_embedding,
s.origins, s.origins,
s.enable_impersonation, s.enable_impersonation,
s.enable_dynamic_client_registration,
s.allow_unauthenticated_dynamic_client_registration,
l.audit_log_retention, l.audit_log_retention,
l.block, l.block,
f.features, f.features,
@@ -66,7 +68,7 @@ select
et.execution_targets, et.execution_targets,
r.allowed_languages r.allowed_languages
from projections.instances i 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 projections.limits l on i.id = l.instance_id
left join features f on i.id = f.instance_id left join features f on i.id = f.instance_id
left join external_domains ed on i.id = ed.instance_id left join external_domains ed on i.id = ed.instance_id
+27
View File
@@ -10,6 +10,7 @@ import (
"testing" "testing"
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language" "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())
})
}
}
+12 -1
View File
@@ -11,7 +11,7 @@ import (
) )
const ( const (
SecurityPolicyProjectionTable = "projections.security_policies2" SecurityPolicyProjectionTable = "projections.security_policies3"
SecurityPolicyColumnInstanceID = "instance_id" SecurityPolicyColumnInstanceID = "instance_id"
SecurityPolicyColumnCreationDate = "creation_date" SecurityPolicyColumnCreationDate = "creation_date"
SecurityPolicyColumnChangeDate = "change_date" SecurityPolicyColumnChangeDate = "change_date"
@@ -19,6 +19,9 @@ const (
SecurityPolicyColumnEnableIframeEmbedding = "enable_iframe_embedding" SecurityPolicyColumnEnableIframeEmbedding = "enable_iframe_embedding"
SecurityPolicyColumnAllowedOrigins = "origins" SecurityPolicyColumnAllowedOrigins = "origins"
SecurityPolicyColumnEnableImpersonation = "enable_impersonation" SecurityPolicyColumnEnableImpersonation = "enable_impersonation"
SecurityPolicyColumnEnableDynamicClientRegistration = "enable_dynamic_client_registration"
SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration = "allow_unauthenticated_dynamic_client_registration"
) )
type securityPolicyProjection struct{} type securityPolicyProjection struct{}
@@ -41,6 +44,8 @@ func (*securityPolicyProjection) Init() *old_handler.Check {
handler.NewColumn(SecurityPolicyColumnEnableIframeEmbedding, handler.ColumnTypeBool, handler.Default(false)), handler.NewColumn(SecurityPolicyColumnEnableIframeEmbedding, handler.ColumnTypeBool, handler.Default(false)),
handler.NewColumn(SecurityPolicyColumnAllowedOrigins, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(SecurityPolicyColumnAllowedOrigins, handler.ColumnTypeTextArray, handler.Nullable()),
handler.NewColumn(SecurityPolicyColumnEnableImpersonation, handler.ColumnTypeBool, handler.Default(false)), 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), handler.NewPrimaryKey(SecurityPolicyColumnInstanceID),
), ),
@@ -87,6 +92,12 @@ func (p *securityPolicyProjection) reduceSecurityPolicySet(event eventstore.Even
if e.EnableImpersonation != nil { if e.EnableImpersonation != nil {
changes = append(changes, handler.NewCol(SecurityPolicyColumnEnableImpersonation, e.EnableImpersonation)) 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( return handler.NewUpsertStatement(
e, e,
[]handler.Column{ []handler.Column{
@@ -1385,6 +1385,9 @@ func (p *relationalTablesProjection) reduceSecurityPolicySet(event eventstore.Ev
EnableIframeEmbedding: policyEvent.EnableIframeEmbedding, EnableIframeEmbedding: policyEvent.EnableIframeEmbedding,
AllowedOrigins: allowedOrigins, AllowedOrigins: allowedOrigins,
EnableImpersonation: policyEvent.EnableImpersonation, EnableImpersonation: policyEvent.EnableImpersonation,
EnableDynamicClientRegistration: policyEvent.EnableDynamicClientRegistration,
AllowUnauthenticatedDynamicClientRegistration: policyEvent.AllowUnauthenticatedDynamicClientRegistration,
}, },
} }
return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings) return settingsRepo.Set(ctx, v3_sql.SQLTx(tx), &settings)
+16 -1
View File
@@ -47,6 +47,14 @@ var (
name: projection.SecurityPolicyColumnEnableImpersonation, name: projection.SecurityPolicyColumnEnableImpersonation,
table: securityPolicyTable, table: securityPolicyTable,
} }
SecurityPolicyColumnEnableDynamicClientRegistration = Column{
name: projection.SecurityPolicyColumnEnableDynamicClientRegistration,
table: securityPolicyTable,
}
SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration = Column{
name: projection.SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration,
table: securityPolicyTable,
}
) )
type SecurityPolicy struct { type SecurityPolicy struct {
@@ -59,6 +67,9 @@ type SecurityPolicy struct {
EnableIframeEmbedding bool EnableIframeEmbedding bool
AllowedOrigins database.TextArray[string] AllowedOrigins database.TextArray[string]
EnableImpersonation bool EnableImpersonation bool
EnableDynamicClientRegistration bool
AllowUnauthenticatedDynamicClientRegistration bool
} }
func (q *Queries) SecurityPolicy(ctx context.Context) (policy *SecurityPolicy, err error) { 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(), SecurityPolicyColumnSequence.identifier(),
SecurityPolicyColumnEnableIframeEmbedding.identifier(), SecurityPolicyColumnEnableIframeEmbedding.identifier(),
SecurityPolicyColumnAllowedOrigins.identifier(), SecurityPolicyColumnAllowedOrigins.identifier(),
SecurityPolicyColumnEnableImpersonation.identifier()). SecurityPolicyColumnEnableImpersonation.identifier(),
SecurityPolicyColumnEnableDynamicClientRegistration.identifier(),
SecurityPolicyColumnAllowUnauthenticatedDynamicClientRegistration.identifier()).
From(securityPolicyTable.identifier()). From(securityPolicyTable.identifier()).
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*SecurityPolicy, error) { func(row *sql.Row) (*SecurityPolicy, error) {
@@ -100,6 +113,8 @@ func prepareSecurityPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*SecurityPo
&securityPolicy.EnableIframeEmbedding, &securityPolicy.EnableIframeEmbedding,
&securityPolicy.AllowedOrigins, &securityPolicy.AllowedOrigins,
&securityPolicy.EnableImpersonation, &securityPolicy.EnableImpersonation,
&securityPolicy.EnableDynamicClientRegistration,
&securityPolicy.AllowUnauthenticatedDynamicClientRegistration,
) )
if err != nil && !errors.Is(err, sql.ErrNoRows) { // ignore not found errors if err != nil && !errors.Is(err, sql.ErrNoRows) { // ignore not found errors
return nil, zerrors.ThrowInternal(err, "QUERY-Dfrt2", "Errors.Internal") return nil, zerrors.ThrowInternal(err, "QUERY-Dfrt2", "Errors.Internal")
@@ -21,6 +21,13 @@ type SecurityPolicySetEvent struct {
EnableIframeEmbedding *bool `json:"enable_iframe_embedding,omitempty"` EnableIframeEmbedding *bool `json:"enable_iframe_embedding,omitempty"`
AllowedOrigins *[]string `json:"allowedOrigins,omitempty"` AllowedOrigins *[]string `json:"allowedOrigins,omitempty"`
EnableImpersonation *bool `json:"enable_impersonation,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( 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{} { func (e *SecurityPolicySetEvent) Payload() interface{} {
return e return e
} }
@@ -14,6 +14,26 @@ message SecuritySettings {
// If enabled, users are allowed to impersonate other users. // If enabled, users are allowed to impersonate other users.
// The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well". // The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well".
bool enable_impersonation = 2; 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{ message EmbeddedIframeSettings{
@@ -806,6 +806,10 @@ message SetSecuritySettingsRequest{
// If enabled, users are allowed to impersonate other users. // If enabled, users are allowed to impersonate other users.
// The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well". // The impersonator needs the appropriate `*_IMPERSONATOR` roles assigned as well".
bool enable_impersonation = 2; 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{ message SetSecuritySettingsResponse{