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.
|
||||
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
|
||||
|
||||
**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 Project Permission Editor | ORG_PROJECT_PERMISSION_EDITOR | Grant Projects to other organizations and view everything needed for this |
|
||||
| Org Project Creator | ORG_PROJECT_CREATOR | This role is used for users in the global organization. They are allowed to create projects and manage them. |
|
||||
| Org Dynamic Client Registrar | ORG_DYNAMIC_CLIENT_REGISTRAR | Register OAuth 2.0 clients through the [dynamic client registration](/guides/integrate/dynamic-client-registration) endpoint, and nothing else |
|
||||
| Org Admin Impersonator | ORG_ADMIN_IMPERSONATOR | Allow impersonation of admin and end users from the organization |
|
||||
| Org Impersonator | ORG_END_USER_IMPERSONATOR | Allow impersonation of end users from the organization |
|
||||
| Project Owner | PROJECT_OWNER | Manage everything within a project. This includes to grant users for the project. |
|
||||
|
||||
Reference in New Issue
Block a user