mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 06:24:25 +00:00
# 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`).
208 lines
7.7 KiB
Go
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 + ")"
|
|
}
|