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

# Which Problems Are Solved

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

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

# How the Problems Are Solved

## Control plane: instance security settings

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

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

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

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

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

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

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

## Registration itself

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

# Additional Changes

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

# Additional Context

- Part of #9810. This is **Phase 1 (DCR)**; #12315 adds RFC 7592 and
#12316 adds CIMD, both stacked on this branch and rebased once this
lands.
- Implements the control-plane and authorization model requested in the
review of this PR, itself a refinement of the direction in
https://github.com/zitadel/zitadel/issues/9810#issuecomment-4891176997.
- **Out of scope (deliberately)**: RFC 7592, CIMD, persisting free-form
metadata (`logo_uri`, `contacts`, and similar), and `private_key_jwt` /
`jwks_uri` (rejected cleanly). No new event types.
- Follow-ups explicitly left out, per the review: DCR client lifecycle
and richer audit, consent and custom scopes, JWT / `aud` (RFC 8707), and
RFC 8414 AS metadata (currently absent from both ZITADEL and
`zitadel/oidc`).
This commit is contained in:
jmarette
2026-07-30 14:43:47 +00:00
committed by GitHub
parent adb2974662
commit f34cc45eac
57 changed files with 2651 additions and 87 deletions
@@ -658,6 +658,36 @@ You could also combine caching with a risk-based on-demand refresh when a critic
Without caching you will call this endpoint on each request.
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. |