mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-22 23:42:16 +00:00
# Which Problems Are Solved #12313 lets clients register themselves as OIDC applications (RFC 7591), but a dynamically registered client has no way to manage itself afterwards: - The registration response returns no `registration_access_token` and no `registration_client_uri`, which RFC 7591 and 7592 clients (including the MCP SDKs) expect in order to manage their own registration. - There is no way to read back, update or delete a registration without an operator acting on the client's behalf through the Console or the Management API. A client that needs to rotate a redirect URI has to ask a human. # How the Problems Are Solved Implement the [OAuth 2.0 Dynamic Client Registration Management Protocol (RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592): `GET`, `PUT` and `DELETE` on `/oauth/v2/register/{client_id}`, wired through the same `op.WithSetRouter` hook as the registration endpoint. ## Control plane: the same security settings, no new switch The management endpoints are gated on the `DynamicClientRegistrationSettings.enabled` introduced in #12313. No new setting, no feature flag: a client that may register may manage what it registered. When registration is disabled the endpoints answer `404`, exactly as `POST /oauth/v2/register` does. `allow_unauthenticated` selects the registration mode and does not apply here, because the registration access token authorizes these endpoints in both modes. ## Authorization: the registration access token, and nothing else RFC 7592 §3 defines the credential as the registration access token issued at registration and bound to a single client, not a user credential. So a client manages only itself, proving it with the token it was handed: | | Registration (`POST`) | Management (`GET` / `PUT` / `DELETE`) | | --- | --- | --- | | Credential | Access token, or none in open mode | Registration access token | | Permission | `project.app.register_dynamic` in token mode | None | Neither `project.app.register_dynamic`, which governs who may *create* clients, nor `project.app.write` applies to the management endpoints. One authorization path per endpoint is easier to reason about than two, and there is no user identity to check against in the first place. Operators keep managing dynamically registered clients through the Management API and the Console, like any other application; they cannot obtain a client's registration access token, by design. Status codes: a missing, malformed or non matching token is `401`; a client that no longer exists is `404`. The token's `client_id` is checked against the path before any lookup, so a token bound to another client is rejected without touching the database. ## The token itself - **Stored like a client secret.** Only its passwap hash is persisted, through a new `project.application.config.oidc.registration_token.changed` event; the plain token leaves the server exactly once, in the response that issues it. - **Bound to its client and organization** through authenticated encryption with the instance key, the same mechanism as refresh tokens. The opaque token handed to the client encrypts `client_id:org_id:secret`, so the endpoint recovers the binding before checking the secret against the stored hash. - **Verified in O(1)** against the hash on the projection, folded into the client lookup the handler already does, with a strongly consistent fallback to the eventstore so a token that was just rotated is accepted before it is projected. - **Rotated on update** (RFC 7592 §4.4) and invalidated implicitly when the client is deleted. A read does not rotate it. Every update rotates, including one that changes no metadata, so a client always gets a usable token back. ## Reusing the existing persistence A small refactor extracts the shared OIDC application creation and update logic (`pushOIDCApplication` gains a variadic `extraEvents`, and `oidcApplicationChangeEvent` is lifted out of `UpdateOIDCApplication`), so the dynamic commands reuse it without the permission check. The registration token event is pushed atomically with the application, so a registered client is never left unmanageable. # Additional Changes - Setup step **74** adds the `registration_token` column to the `apps7_oidc_configs` projection, with a reducer for the new event modelled on the client secret. Applications are not part of the relational storage projection, so there is no second write path to keep in sync. - The registration response (`POST`) now also carries `registration_access_token` and `registration_client_uri`. - Docs: a "Manage a registration" section in the dynamic client registration guide covering the three operations, the authorization model, token rotation and the new limitations, plus the management endpoints on the OIDC endpoints page. - Unit tests for the command layer (issue, verify, rotate, update, delete), the projection reducer, and the token binding and response mapping. An integration test covers the full lifecycle (register, read, update with rotation, delete, read again with `404`), a token bound to another client, and the endpoints not being served when registration is disabled. None of the integration calls carries a user access token, which pins down that the management endpoints require no permission of their own. # Additional Context - Part of the MCP authorization effort in #9810. Follow-up to #12313 (RFC 7591), which is now merged; this branch has been rebased on `main` and carries the settings and authorization model agreed there. - **Open question for the review**: the authorization model above (registration access token only, no operator path through these endpoints) is the one I proposed when #12313 was reviewed, but it was never explicitly confirmed. If you would rather have `project.app.write` also grant access to a client in the operator's own organization, that is a contained change in `authorizeClientManagement`. - Rotation is eventually consistent: right after an update the previous token may keep working for a brief moment until the rotation is projected, while the new token works immediately through the eventstore fallback. This matches how ZITADEL validates access tokens, where revocation is likewise eventually consistent. It is documented in the guide and asserted as such in the integration test. - A database migration is introduced for the new projection column. No proto change and no new feature flag. - Verified locally: full build, `go vet` including `-tags integration`, `golangci-lint` with 0 issues, and the unit suites for every touched package. As before, I cannot run the integration suite here, so CI remains the check on it.
215 lines
12 KiB
Plaintext
215 lines
12 KiB
Plaintext
---
|
|
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.
|
|
|
|
A registered client can later read, update and delete its own registration through the
|
|
[OAuth 2.0 Dynamic Client Registration Management Protocol (RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592),
|
|
using the registration access token it receives at registration. See [Manage a registration](#manage-a-registration).
|
|
|
|
<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,
|
|
"registration_access_token": "kFqx...redacted...",
|
|
"registration_client_uri": "https://${CUSTOM_DOMAIN}/oauth/v2/register/340396026519785524",
|
|
"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).
|
|
|
|
The `registration_access_token` and `registration_client_uri` let the client manage its own registration afterwards, see
|
|
[Manage a registration](#manage-a-registration). Store the registration access token securely: it is only returned at
|
|
registration and when the registration is updated, and it is never stored in clear text on the server.
|
|
|
|
## 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`).
|
|
|
|
## Manage a registration
|
|
|
|
ZITADEL implements the [OAuth 2.0 Dynamic Client Registration Management Protocol (RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592).
|
|
A registered client manages itself at the client configuration endpoint returned as `registration_client_uri`
|
|
(`${CUSTOM_DOMAIN}/oauth/v2/register/{client_id}`), authenticating with the `registration_access_token` from the
|
|
registration response. The endpoints are served whenever `dynamicClientRegistration.enabled` is set, the same switch as
|
|
the registration endpoint itself: a client that may register may manage what it registered. `allowUnauthenticated`
|
|
selects the [registration mode](#registration-modes) and does not apply here, as the registration access token
|
|
authorizes these endpoints in both modes.
|
|
|
|
The registration access token is bound to a single client. Send it as an `Authorization: Bearer` header. A missing or
|
|
invalid token is rejected with `401 Unauthorized`; once a client has been deleted its endpoint returns `404 Not Found`.
|
|
|
|
The registration access token is the only authorization path: a client manages itself and needs no user permission, so
|
|
neither `project.app.register_dynamic` (which governs who may *create* clients) nor `project.app.write` applies. As
|
|
operators cannot obtain the token, they manage dynamically registered clients through the Management API and the
|
|
Console like any other application, not through these endpoints.
|
|
|
|
### Read
|
|
|
|
```bash
|
|
curl --request GET \
|
|
--url ${CUSTOM_DOMAIN}/oauth/v2/register/340396026519785524 \
|
|
--header 'Authorization: Bearer <registration_access_token>'
|
|
```
|
|
|
|
Returns `HTTP 200` with the current client information response. The `client_secret` is never returned, as it is not
|
|
stored in clear text.
|
|
|
|
### Update
|
|
|
|
An update replaces the stored metadata, it does not merge into it. [RFC 7592 §2.2](https://datatracker.ietf.org/doc/html/rfc7592#section-2.2)
|
|
therefore requires the client to include **all** metadata fields it received from the registration, read or update
|
|
response, not only the ones it wants to change. Send the full set, with the new values in place:
|
|
|
|
```bash
|
|
curl --request PUT \
|
|
--url ${CUSTOM_DOMAIN}/oauth/v2/register/340396026519785524 \
|
|
--header 'Authorization: Bearer <registration_access_token>' \
|
|
--header 'Content-Type: application/json' \
|
|
--data '{
|
|
"client_id": "340396026519785524",
|
|
"redirect_uris": ["https://client.example.com/callback", "https://client.example.com/callback2"],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none"
|
|
}'
|
|
```
|
|
|
|
Omitted members are not preserved: they are re-defaulted exactly as during registration, using the same validation and
|
|
defaulting rules. Dropping `grant_types` from the body above would reset the client to the default
|
|
`["authorization_code"]` and silently lose `refresh_token`, and dropping `token_endpoint_auth_method` would re-derive it
|
|
from the application type. Read the registration back first if the client does not keep its metadata.
|
|
|
|
Returns `HTTP 200` with the updated registration. An update rotates the registration access token: the response contains
|
|
a new `registration_access_token`, so the client must replace its stored token with the new value. Every update rotates
|
|
the token, including an update that does not change any metadata. The new token works immediately; the previous token is
|
|
invalidated through the eventually consistent projection, so it may keep working for a brief moment after the update.
|
|
|
|
### Delete
|
|
|
|
```bash
|
|
curl --request DELETE \
|
|
--url ${CUSTOM_DOMAIN}/oauth/v2/register/340396026519785524 \
|
|
--header 'Authorization: Bearer <registration_access_token>'
|
|
```
|
|
|
|
Returns `HTTP 204 No Content` and removes the client.
|
|
|
|
## Limitations
|
|
|
|
This 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`.
|
|
- 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.
|
|
- A registration access token is invalidated by rotating it (through an update) or by deleting the client; there is no
|
|
separate per-token revocation endpoint.
|
|
- The read and update responses return the current metadata but not `client_name` or `client_id_issued_at` (not
|
|
persisted in this version), and never return `client_secret` (only its hash is stored).
|
|
- Changing `token_endpoint_auth_method` to a confidential method (`client_secret_basic` or `client_secret_post`) through
|
|
an update does not issue a `client_secret`; register a new confidential client instead.
|
|
- A client whose application, project or organization is deactivated (not deleted) returns `404` from the management
|
|
endpoints until it is reactivated.
|