Files
zitadel/internal/command/project_application_oidc_dcr.go
T
jmaretteandGitHub f34cc45eac 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`).
2026-07-30 14:43:47 +00:00

208 lines
7.7 KiB
Go

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 + ")"
}