mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "Има разрешение да управлява собствените си потребители",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "自身を管理する権限がある",
|
||||||
|
|||||||
@@ -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": "Има дозвола за менаџирање на своите корисници",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Имеет разрешение на управление своим собственным пользователем",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "有权管理自己的用户",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muhlemmer/gu"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
|
"github.com/zitadel/zitadel/internal/command"
|
||||||
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
|
"github.com/zitadel/zitadel/internal/query"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registrationMaxBodyBytes bounds the size of a dynamic client registration request body.
|
||||||
|
const registrationMaxBodyBytes = 100 * 1024
|
||||||
|
|
||||||
|
// dynamicClientRegistration handles POST requests to the OAuth 2.0 Dynamic Client
|
||||||
|
// Registration endpoint (RFC 7591). The route is always mounted; it only registers clients
|
||||||
|
// when dynamic client registration is enabled in the instance's security settings,
|
||||||
|
// otherwise it behaves as if the endpoint did not exist.
|
||||||
|
func (s *Server) dynamicClientRegistration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
if !authz.GetInstance(ctx).EnableDynamicClientRegistration() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, resourceOwner, err := s.dynamicClientRegistrationResourceOwner(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
// The caller authenticated but is not allowed to register clients: RFC 6750 §3.1
|
||||||
|
// insufficient_scope, rather than pretending the token itself is invalid.
|
||||||
|
case errors.Is(err, errRegistrationForbidden):
|
||||||
|
s.writeRegistrationForbidden(ctx, w)
|
||||||
|
// Only missing/invalid credentials are a 401; unexpected errors (e.g. a failed
|
||||||
|
// token lookup) must not be masked as invalid_token.
|
||||||
|
case zerrors.IsUnauthenticated(err), zerrors.IsPermissionDenied(err):
|
||||||
|
s.writeRegistrationUnauthorized(ctx, w)
|
||||||
|
default:
|
||||||
|
s.writeRegistrationServerError(ctx, w, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req clientRegistrationRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, registrationMaxBodyBytes)).Decode(&req); err != nil {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusRequestEntityTooLarge, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body is too large"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body could not be parsed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app, regErr := req.toOIDCApp()
|
||||||
|
if regErr != nil {
|
||||||
|
s.writeRegistrationError(ctx, w, regErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err := s.ensureDCRProject(ctx, resourceOwner)
|
||||||
|
if err != nil {
|
||||||
|
s.writeRegistrationServerError(ctx, w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registered, err := s.command.AddDynamicOIDCClient(ctx, projectID, resourceOwner, app)
|
||||||
|
if err != nil {
|
||||||
|
s.writeRegistrationCommandError(ctx, w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusCreated, newClientRegistrationResponse(registered, req.ClientName, time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamicClientRegistrationResourceOwner authorizes the registration and returns the
|
||||||
|
// organization the client is homed in.
|
||||||
|
//
|
||||||
|
// In token mode (the default) the caller presents an access token, the client is homed in
|
||||||
|
// the token's organization and the token's user must hold the org-scoped
|
||||||
|
// project.app.register_dynamic permission there. That permission is deliberately not
|
||||||
|
// project.app.write: a service user can be allowed to self-register clients — through the
|
||||||
|
// dedicated ORG_DYNAMIC_CLIENT_REGISTRAR role — without gaining write access to existing
|
||||||
|
// applications.
|
||||||
|
//
|
||||||
|
// In open mode (allow_unauthenticated in the instance's security settings) no token is
|
||||||
|
// required and no permission is checked, as required by the Model Context Protocol flow.
|
||||||
|
// Those clients are homed in the instance's default organization.
|
||||||
|
//
|
||||||
|
// The check runs before any state is created, so it also gates the auto-provisioning of the
|
||||||
|
// organization's dedicated DCR project.
|
||||||
|
func (s *Server) dynamicClientRegistrationResourceOwner(ctx context.Context, r *http.Request) (context.Context, string, error) {
|
||||||
|
token := bearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
if !authz.GetInstance(ctx).AllowUnauthenticatedDynamicClientRegistration() {
|
||||||
|
return ctx, "", zerrors.ThrowUnauthenticated(nil, "OIDC-Eich8", "Errors.Token.Invalid")
|
||||||
|
}
|
||||||
|
return ctx, authz.GetInstance(ctx).DefaultOrganisationID(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.verifyAccessToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return ctx, "", err
|
||||||
|
}
|
||||||
|
// The permission check resolves the caller's memberships from the context, which the
|
||||||
|
// OIDC endpoints do not populate: unlike the gRPC APIs they are not behind the
|
||||||
|
// authorization interceptor. Derive it from the verified token, as the token exchange
|
||||||
|
// does for the impersonating actor.
|
||||||
|
ctx = authz.SetCtxData(ctx, authz.CtxData{
|
||||||
|
UserID: accessToken.userID,
|
||||||
|
OrgID: accessToken.resourceOwner,
|
||||||
|
})
|
||||||
|
if err := s.command.CheckPermissionRegisterDynamicClient(ctx, accessToken.resourceOwner); err != nil {
|
||||||
|
// verifyAccessToken also reports invalid tokens as permission denied, so mark the
|
||||||
|
// authorization failure explicitly to keep 401 and 403 apart.
|
||||||
|
return ctx, "", fmt.Errorf("%w: %w", errRegistrationForbidden, err)
|
||||||
|
}
|
||||||
|
return ctx, accessToken.resourceOwner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRegistrationForbidden marks a caller that presented a valid access token but does not
|
||||||
|
// hold the project.app.register_dynamic permission in the token's organization.
|
||||||
|
var errRegistrationForbidden = errors.New("dynamic client registration not permitted")
|
||||||
|
|
||||||
|
// ensureDCRProject returns the dedicated project that holds dynamically registered clients
|
||||||
|
// for the organization, creating it on first use. The common case (the project already
|
||||||
|
// exists) is served from the projection; the creation and the concurrent-creation race are
|
||||||
|
// delegated to the command, which resolves them strongly consistently from the eventstore.
|
||||||
|
func (s *Server) ensureDCRProject(ctx context.Context, resourceOwner string) (string, error) {
|
||||||
|
projectID, err := s.dcrProjectIDFromProjection(ctx, resourceOwner)
|
||||||
|
if err != nil || projectID != "" {
|
||||||
|
return projectID, err
|
||||||
|
}
|
||||||
|
return s.command.EnsureDCRProject(ctx, resourceOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dcrProjectIDFromProjection(ctx context.Context, resourceOwner string) (string, error) {
|
||||||
|
nameQuery, err := query.NewProjectNameSearchQuery(query.TextEquals, command.DCRProjectName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ownerQuery, err := query.NewProjectResourceOwnerSearchQuery(resourceOwner)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// No permission check: access to the registration endpoint is the authorization
|
||||||
|
// boundary for dynamic client registration.
|
||||||
|
projects, err := s.query.SearchProjects(ctx, &query.ProjectSearchQueries{Queries: []query.SearchQuery{nameQuery, ownerQuery}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(projects.Projects) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return projects.Projects[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
scheme, token, found := strings.Cut(auth, " ")
|
||||||
|
if !found || !strings.EqualFold(scheme, "Bearer") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationJSON(ctx context.Context, w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration: encode response", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationError(ctx context.Context, w http.ResponseWriter, regErr *registrationError) {
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusBadRequest, regErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationUnauthorized(ctx context.Context, w http.ResponseWriter) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusUnauthorized, newRegistrationError("invalid_token", "a valid access token is required to register a client"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationForbidden(ctx context.Context, w http.ResponseWriter) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`)
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusForbidden, newRegistrationError("insufficient_scope", "the access token is not allowed to register clients"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationServerError(ctx context.Context, w http.ResponseWriter, err error) {
|
||||||
|
s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration", "err", err)
|
||||||
|
s.writeRegistrationJSON(ctx, w, http.StatusInternalServerError, newRegistrationError("server_error", "the client could not be registered"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRegistrationCommandError(ctx context.Context, w http.ResponseWriter, err error) {
|
||||||
|
if zerrors.IsErrorInvalidArgument(err) {
|
||||||
|
s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeRegistrationServerError(ctx, w, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth 2.0 Dynamic Client Registration error codes as defined in RFC 7591 §3.2.2.
|
||||||
|
const (
|
||||||
|
registrationErrorInvalidRedirectURI = "invalid_redirect_uri"
|
||||||
|
registrationErrorInvalidClientMetadata = "invalid_client_metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registrationError is an OAuth 2.0 Dynamic Client Registration error response
|
||||||
|
// (RFC 7591 §3.2.2). Invalid client metadata is returned with HTTP status 400; the same
|
||||||
|
// shape is reused for the 401 (invalid_token) and 500 (server_error) responses.
|
||||||
|
type registrationError struct {
|
||||||
|
ErrorType string `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *registrationError) Error() string {
|
||||||
|
if e.ErrorDescription == "" {
|
||||||
|
return e.ErrorType
|
||||||
|
}
|
||||||
|
return e.ErrorType + ": " + e.ErrorDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistrationError(errorType, description string) *registrationError {
|
||||||
|
return ®istrationError{ErrorType: errorType, ErrorDescription: description}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientRegistrationRequest holds the client metadata of an OAuth 2.0 Dynamic Client
|
||||||
|
// Registration request (RFC 7591 §2, extended by OpenID Connect Dynamic Client
|
||||||
|
// Registration 1.0 §2). Only the members relevant for a ZITADEL OIDC application are
|
||||||
|
// interpreted. Free-form metadata (client_uri, logo_uri, contacts, etc.) is accepted but
|
||||||
|
// not persisted in this version. Members that imply unsupported behaviour (jwks/jwks_uri,
|
||||||
|
// private_key_jwt) are rejected explicitly.
|
||||||
|
type clientRegistrationRequest struct {
|
||||||
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
|
ApplicationType string `json:"application_type,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
|
PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"`
|
||||||
|
JWKsURI string `json:"jwks_uri,omitempty"`
|
||||||
|
JWKs json.RawMessage `json:"jwks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientRegistrationResponse is the client information response of a successful
|
||||||
|
// registration (RFC 7591 §3.2.1).
|
||||||
|
type clientRegistrationResponse struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret,omitempty"`
|
||||||
|
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
||||||
|
ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"`
|
||||||
|
|
||||||
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
|
ApplicationType string `json:"application_type,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
|
PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toOIDCApp maps the registration request to a domain OIDC application, applying the RFC
|
||||||
|
// 7591 defaults and rejecting unsupported metadata. The returned application is not yet
|
||||||
|
// persisted; the application name and the client credentials are assigned by the command.
|
||||||
|
func (req *clientRegistrationRequest) toOIDCApp() (*domain.OIDCApp, *registrationError) {
|
||||||
|
if req.JWKsURI != "" || len(req.JWKs) > 0 {
|
||||||
|
return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "jwks and jwks_uri are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURIs := trimSpaceSlice(req.RedirectURIs)
|
||||||
|
if len(redirectURIs) == 0 {
|
||||||
|
return nil, newRegistrationError(registrationErrorInvalidRedirectURI, "at least one redirect_uri is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationType, regErr := registrationApplicationTypeToDomain(req.ApplicationType, redirectURIs)
|
||||||
|
if regErr != nil {
|
||||||
|
return nil, regErr
|
||||||
|
}
|
||||||
|
authMethod, regErr := registrationAuthMethodToDomain(req.TokenEndpointAuthMethod, applicationType)
|
||||||
|
if regErr != nil {
|
||||||
|
return nil, regErr
|
||||||
|
}
|
||||||
|
grantTypes, regErr := registrationGrantTypesToDomain(req.GrantTypes)
|
||||||
|
if regErr != nil {
|
||||||
|
return nil, regErr
|
||||||
|
}
|
||||||
|
responseTypes, regErr := registrationResponseTypesToDomain(req.ResponseTypes)
|
||||||
|
if regErr != nil {
|
||||||
|
return nil, regErr
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &domain.OIDCApp{
|
||||||
|
AppName: strings.TrimSpace(req.ClientName),
|
||||||
|
RedirectUris: redirectURIs,
|
||||||
|
ResponseTypes: responseTypes,
|
||||||
|
GrantTypes: grantTypes,
|
||||||
|
ApplicationType: gu.Ptr(applicationType),
|
||||||
|
AuthMethodType: gu.Ptr(authMethod),
|
||||||
|
PostLogoutRedirectUris: trimSpaceSlice(req.PostLogoutRedirectURIs),
|
||||||
|
OIDCVersion: gu.Ptr(domain.OIDCVersionV1),
|
||||||
|
AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !app.IsValid() {
|
||||||
|
return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested grant and response type combination is not supported")
|
||||||
|
}
|
||||||
|
if compliance := domain.GetOIDCV1Compliance(app.ApplicationType, app.GrantTypes, app.AuthMethodType, app.RedirectUris); compliance.NoneCompliant {
|
||||||
|
return nil, registrationComplianceError(compliance)
|
||||||
|
}
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registrationComplianceError maps a domain compliance failure to the matching RFC 7591
|
||||||
|
// error. Auth-method and grant/response combination problems are client metadata errors;
|
||||||
|
// redirect URI scheme problems are reported as an invalid redirect URI. The internal
|
||||||
|
// compliance keys (see internal/domain/application_oidc.go) are not exposed to the client.
|
||||||
|
func registrationComplianceError(compliance *domain.Compliance) *registrationError {
|
||||||
|
for _, problem := range compliance.Problems {
|
||||||
|
if strings.Contains(problem, "AuthMethodType") || strings.Contains(problem, "GrantType") || strings.Contains(problem, "Combinations") {
|
||||||
|
return newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newRegistrationError(registrationErrorInvalidRedirectURI, "one or more redirect_uris are invalid for the requested application type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationAuthMethodToDomain(method string, applicationType domain.OIDCApplicationType) (domain.OIDCAuthMethodType, *registrationError) {
|
||||||
|
switch method {
|
||||||
|
case "":
|
||||||
|
// Default the auth method to the application type. Native and user-agent
|
||||||
|
// applications must be public (none); web applications default to
|
||||||
|
// client_secret_basic per RFC 7591 §2.
|
||||||
|
if applicationType == domain.OIDCApplicationTypeNative || applicationType == domain.OIDCApplicationTypeUserAgent {
|
||||||
|
return domain.OIDCAuthMethodTypeNone, nil
|
||||||
|
}
|
||||||
|
return domain.OIDCAuthMethodTypeBasic, nil
|
||||||
|
case "client_secret_basic":
|
||||||
|
return domain.OIDCAuthMethodTypeBasic, nil
|
||||||
|
case "client_secret_post":
|
||||||
|
return domain.OIDCAuthMethodTypePost, nil
|
||||||
|
case "none":
|
||||||
|
return domain.OIDCAuthMethodTypeNone, nil
|
||||||
|
default:
|
||||||
|
return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "token_endpoint_auth_method "+method+" is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationGrantTypesToDomain(grantTypes []string) ([]domain.OIDCGrantType, *registrationError) {
|
||||||
|
if len(grantTypes) == 0 {
|
||||||
|
// RFC 7591 §2: authorization_code is the default.
|
||||||
|
return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, nil
|
||||||
|
}
|
||||||
|
mapped := make([]domain.OIDCGrantType, 0, len(grantTypes))
|
||||||
|
for _, grantType := range grantTypes {
|
||||||
|
switch grantType {
|
||||||
|
case "authorization_code":
|
||||||
|
mapped = append(mapped, domain.OIDCGrantTypeAuthorizationCode)
|
||||||
|
case "refresh_token":
|
||||||
|
mapped = append(mapped, domain.OIDCGrantTypeRefreshToken)
|
||||||
|
case "implicit":
|
||||||
|
mapped = append(mapped, domain.OIDCGrantTypeImplicit)
|
||||||
|
case "urn:ietf:params:oauth:grant-type:device_code":
|
||||||
|
mapped = append(mapped, domain.OIDCGrantTypeDeviceCode)
|
||||||
|
case "urn:ietf:params:oauth:grant-type:token-exchange":
|
||||||
|
mapped = append(mapped, domain.OIDCGrantTypeTokenExchange)
|
||||||
|
default:
|
||||||
|
return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "grant_type "+grantType+" is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationResponseTypesToDomain(responseTypes []string) ([]domain.OIDCResponseType, *registrationError) {
|
||||||
|
if len(responseTypes) == 0 {
|
||||||
|
// RFC 7591 §2: code is the default.
|
||||||
|
return []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, nil
|
||||||
|
}
|
||||||
|
mapped := make([]domain.OIDCResponseType, 0, len(responseTypes))
|
||||||
|
for _, responseType := range responseTypes {
|
||||||
|
switch normalizeResponseType(responseType) {
|
||||||
|
case "code":
|
||||||
|
mapped = append(mapped, domain.OIDCResponseTypeCode)
|
||||||
|
case "id_token":
|
||||||
|
mapped = append(mapped, domain.OIDCResponseTypeIDToken)
|
||||||
|
case "id_token token":
|
||||||
|
mapped = append(mapped, domain.OIDCResponseTypeIDTokenToken)
|
||||||
|
default:
|
||||||
|
return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "response_type "+responseType+" is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeResponseType sorts the space separated values of a response_type so that the
|
||||||
|
// order of "token" and "id_token" does not matter.
|
||||||
|
func normalizeResponseType(responseType string) string {
|
||||||
|
fields := strings.Fields(responseType)
|
||||||
|
hasToken, hasIDToken, hasCode := false, false, false
|
||||||
|
for _, field := range fields {
|
||||||
|
switch field {
|
||||||
|
case "token":
|
||||||
|
hasToken = true
|
||||||
|
case "id_token":
|
||||||
|
hasIDToken = true
|
||||||
|
case "code":
|
||||||
|
hasCode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case hasCode && !hasToken && !hasIDToken:
|
||||||
|
return "code"
|
||||||
|
case hasIDToken && hasToken && !hasCode:
|
||||||
|
return "id_token token"
|
||||||
|
case hasIDToken && !hasToken && !hasCode:
|
||||||
|
return "id_token"
|
||||||
|
default:
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registrationApplicationTypeToDomain resolves the application type of a registration
|
||||||
|
// request. An explicit application_type is always honoured; it is only inferred when the
|
||||||
|
// member is absent.
|
||||||
|
//
|
||||||
|
// OpenID Connect Dynamic Client Registration 1.0 §2 defaults to web, but application_type is
|
||||||
|
// an OpenID Connect member: clients that only implement RFC 7591, which does not define it,
|
||||||
|
// omit it. Native hosts doing so (Cursor and other MCP clients) register custom-scheme
|
||||||
|
// redirect URIs, which ZITADEL reserves for native applications, so the web default would
|
||||||
|
// reject them for a redirect URI that is in fact valid for what they are. Infer native in
|
||||||
|
// exactly that case; everything that is accepted today keeps its current type.
|
||||||
|
func registrationApplicationTypeToDomain(applicationType string, redirectURIs []string) (domain.OIDCApplicationType, *registrationError) {
|
||||||
|
switch applicationType {
|
||||||
|
case "":
|
||||||
|
if domain.OIDCRedirectURIsRequireNative(redirectURIs) {
|
||||||
|
return domain.OIDCApplicationTypeNative, nil
|
||||||
|
}
|
||||||
|
return domain.OIDCApplicationTypeWeb, nil
|
||||||
|
case "web":
|
||||||
|
return domain.OIDCApplicationTypeWeb, nil
|
||||||
|
case "native":
|
||||||
|
return domain.OIDCApplicationTypeNative, nil
|
||||||
|
default:
|
||||||
|
return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "application_type "+applicationType+" is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newClientRegistrationResponse builds the RFC 7591 §3.2.1 response from the registered
|
||||||
|
// application. The client name is echoed from the request, as the human-readable name is
|
||||||
|
// not persisted as-is in this version.
|
||||||
|
func newClientRegistrationResponse(app *domain.OIDCApp, clientName string, issuedAt int64) *clientRegistrationResponse {
|
||||||
|
resp := &clientRegistrationResponse{
|
||||||
|
ClientID: app.ClientID,
|
||||||
|
ClientSecret: app.ClientSecretString,
|
||||||
|
ClientIDIssuedAt: issuedAt,
|
||||||
|
RedirectURIs: app.RedirectUris,
|
||||||
|
ResponseTypes: responseTypesToRegistration(app.ResponseTypes),
|
||||||
|
GrantTypes: grantTypesToRegistration(app.GrantTypes),
|
||||||
|
ApplicationType: applicationTypeToRegistration(gu.Value(app.ApplicationType)),
|
||||||
|
ClientName: strings.TrimSpace(clientName),
|
||||||
|
TokenEndpointAuthMethod: authMethodToRegistration(gu.Value(app.AuthMethodType)),
|
||||||
|
PostLogoutRedirectURIs: app.PostLogoutRedirectUris,
|
||||||
|
}
|
||||||
|
if app.ClientSecretString != "" {
|
||||||
|
// RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED if client_secret is
|
||||||
|
// issued. 0 means the secret never expires.
|
||||||
|
resp.ClientSecretExpiresAt = gu.Ptr(int64(0))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func authMethodToRegistration(authMethod domain.OIDCAuthMethodType) string {
|
||||||
|
switch authMethod {
|
||||||
|
case domain.OIDCAuthMethodTypeBasic:
|
||||||
|
return "client_secret_basic"
|
||||||
|
case domain.OIDCAuthMethodTypePost:
|
||||||
|
return "client_secret_post"
|
||||||
|
case domain.OIDCAuthMethodTypeNone:
|
||||||
|
return "none"
|
||||||
|
case domain.OIDCAuthMethodTypePrivateKeyJWT:
|
||||||
|
return "private_key_jwt"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantTypesToRegistration(grantTypes []domain.OIDCGrantType) []string {
|
||||||
|
mapped := make([]string, 0, len(grantTypes))
|
||||||
|
for _, grantType := range grantTypes {
|
||||||
|
switch grantType {
|
||||||
|
case domain.OIDCGrantTypeAuthorizationCode:
|
||||||
|
mapped = append(mapped, "authorization_code")
|
||||||
|
case domain.OIDCGrantTypeRefreshToken:
|
||||||
|
mapped = append(mapped, "refresh_token")
|
||||||
|
case domain.OIDCGrantTypeImplicit:
|
||||||
|
mapped = append(mapped, "implicit")
|
||||||
|
case domain.OIDCGrantTypeDeviceCode:
|
||||||
|
mapped = append(mapped, "urn:ietf:params:oauth:grant-type:device_code")
|
||||||
|
case domain.OIDCGrantTypeTokenExchange:
|
||||||
|
mapped = append(mapped, "urn:ietf:params:oauth:grant-type:token-exchange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseTypesToRegistration(responseTypes []domain.OIDCResponseType) []string {
|
||||||
|
mapped := make([]string, 0, len(responseTypes))
|
||||||
|
for _, responseType := range responseTypes {
|
||||||
|
switch responseType {
|
||||||
|
case domain.OIDCResponseTypeCode:
|
||||||
|
mapped = append(mapped, "code")
|
||||||
|
case domain.OIDCResponseTypeIDToken:
|
||||||
|
mapped = append(mapped, "id_token")
|
||||||
|
case domain.OIDCResponseTypeIDTokenToken:
|
||||||
|
mapped = append(mapped, "id_token token")
|
||||||
|
case domain.OIDCResponseTypeUnspecified:
|
||||||
|
// not exposed through dynamic client registration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationTypeToRegistration(applicationType domain.OIDCApplicationType) string {
|
||||||
|
switch applicationType {
|
||||||
|
case domain.OIDCApplicationTypeWeb:
|
||||||
|
return "web"
|
||||||
|
case domain.OIDCApplicationTypeNative:
|
||||||
|
return "native"
|
||||||
|
case domain.OIDCApplicationTypeUserAgent:
|
||||||
|
return "user_agent"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpaceSlice(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trimmed := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
trimmed = append(trimmed, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user