package oidc import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/muhlemmer/gu" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" ) // registrationMaxBodyBytes bounds the size of a dynamic client registration request body. const registrationMaxBodyBytes = 100 * 1024 // dynamicClientRegistration handles POST requests to the OAuth 2.0 Dynamic Client // Registration endpoint (RFC 7591). The route is always mounted; it only registers clients // when dynamic client registration is enabled in the instance's security settings, // otherwise it behaves as if the endpoint did not exist. func (s *Server) dynamicClientRegistration(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !authz.GetInstance(ctx).EnableDynamicClientRegistration() { http.NotFound(w, r) return } ctx, resourceOwner, err := s.dynamicClientRegistrationResourceOwner(ctx, r) if err != nil { switch { // The caller authenticated but is not allowed to register clients: RFC 6750 §3.1 // insufficient_scope, rather than pretending the token itself is invalid. case errors.Is(err, errRegistrationForbidden): s.writeRegistrationForbidden(ctx, w) // Only missing/invalid credentials are a 401; unexpected errors (e.g. a failed // token lookup) must not be masked as invalid_token. case zerrors.IsUnauthenticated(err), zerrors.IsPermissionDenied(err): s.writeRegistrationUnauthorized(ctx, w) default: s.writeRegistrationServerError(ctx, w, err) } return } var req clientRegistrationRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, registrationMaxBodyBytes)).Decode(&req); err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { s.writeRegistrationJSON(ctx, w, http.StatusRequestEntityTooLarge, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body is too large")) return } s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the request body could not be parsed")) return } app, regErr := req.toOIDCApp() if regErr != nil { s.writeRegistrationError(ctx, w, regErr) return } projectID, err := s.ensureDCRProject(ctx, resourceOwner) if err != nil { s.writeRegistrationServerError(ctx, w, err) return } registered, err := s.command.AddDynamicOIDCClient(ctx, projectID, resourceOwner, app) if err != nil { s.writeRegistrationCommandError(ctx, w, err) return } resp := newClientRegistrationResponse(registered, req.ClientName, time.Now().Unix()) resp.RegistrationAccessToken, err = s.registrationAccessToken(registered.ClientID, resourceOwner, registered.RegistrationAccessToken) if err != nil { s.writeRegistrationServerError(ctx, w, err) return } resp.RegistrationClientURI = s.registrationClientURI(ctx, registered.ClientID) s.writeRegistrationJSON(ctx, w, http.StatusCreated, resp) } // dynamicClientRegistrationResourceOwner authorizes the registration and returns the // organization the client is homed in. // // In token mode (the default) the caller presents an access token, the client is homed in // the token's organization and the token's user must hold the org-scoped // project.app.register_dynamic permission there. That permission is deliberately not // project.app.write: a service user can be allowed to self-register clients — through the // dedicated ORG_DYNAMIC_CLIENT_REGISTRAR role — without gaining write access to existing // applications. // // In open mode (allow_unauthenticated in the instance's security settings) no token is // required and no permission is checked, as required by the Model Context Protocol flow. // Those clients are homed in the instance's default organization. // // The check runs before any state is created, so it also gates the auto-provisioning of the // organization's dedicated DCR project. func (s *Server) dynamicClientRegistrationResourceOwner(ctx context.Context, r *http.Request) (context.Context, string, error) { token := bearerToken(r) if token == "" { if !authz.GetInstance(ctx).AllowUnauthenticatedDynamicClientRegistration() { return ctx, "", zerrors.ThrowUnauthenticated(nil, "OIDC-Eich8", "Errors.Token.Invalid") } return ctx, authz.GetInstance(ctx).DefaultOrganisationID(), nil } accessToken, err := s.verifyAccessToken(ctx, token) if err != nil { return ctx, "", err } // The permission check resolves the caller's memberships from the context, which the // OIDC endpoints do not populate: unlike the gRPC APIs they are not behind the // authorization interceptor. Derive it from the verified token, as the token exchange // does for the impersonating actor. ctx = authz.SetCtxData(ctx, authz.CtxData{ UserID: accessToken.userID, OrgID: accessToken.resourceOwner, }) if err := s.command.CheckPermissionRegisterDynamicClient(ctx, accessToken.resourceOwner); err != nil { // verifyAccessToken also reports invalid tokens as permission denied, so mark the // authorization failure explicitly to keep 401 and 403 apart. return ctx, "", fmt.Errorf("%w: %w", errRegistrationForbidden, err) } return ctx, accessToken.resourceOwner, nil } // errRegistrationForbidden marks a caller that presented a valid access token but does not // hold the project.app.register_dynamic permission in the token's organization. var errRegistrationForbidden = errors.New("dynamic client registration not permitted") // ensureDCRProject returns the dedicated project that holds dynamically registered clients // for the organization, creating it on first use. The common case (the project already // exists) is served from the projection; the creation and the concurrent-creation race are // delegated to the command, which resolves them strongly consistently from the eventstore. func (s *Server) ensureDCRProject(ctx context.Context, resourceOwner string) (string, error) { projectID, err := s.dcrProjectIDFromProjection(ctx, resourceOwner) if err != nil || projectID != "" { return projectID, err } return s.command.EnsureDCRProject(ctx, resourceOwner) } func (s *Server) dcrProjectIDFromProjection(ctx context.Context, resourceOwner string) (string, error) { nameQuery, err := query.NewProjectNameSearchQuery(query.TextEquals, command.DCRProjectName) if err != nil { return "", err } ownerQuery, err := query.NewProjectResourceOwnerSearchQuery(resourceOwner) if err != nil { return "", err } // No permission check: access to the registration endpoint is the authorization // boundary for dynamic client registration. projects, err := s.query.SearchProjects(ctx, &query.ProjectSearchQueries{Queries: []query.SearchQuery{nameQuery, ownerQuery}}, nil) if err != nil { return "", err } if len(projects.Projects) == 0 { return "", nil } return projects.Projects[0].ID, nil } func bearerToken(r *http.Request) string { auth := r.Header.Get("Authorization") scheme, token, found := strings.Cut(auth, " ") if !found || !strings.EqualFold(scheme, "Bearer") { return "" } return strings.TrimSpace(token) } func (s *Server) writeRegistrationJSON(ctx context.Context, w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(body); err != nil { s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration: encode response", "err", err) } } func (s *Server) writeRegistrationError(ctx context.Context, w http.ResponseWriter, regErr *registrationError) { s.writeRegistrationJSON(ctx, w, http.StatusBadRequest, regErr) } // writeRegistrationUnauthorized answers a 401 for both the registration endpoint (a missing // or invalid initial access token) and the management endpoints (a missing or invalid // registration access token), so the description is kept token-neutral. func (s *Server) writeRegistrationUnauthorized(ctx context.Context, w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) s.writeRegistrationJSON(ctx, w, http.StatusUnauthorized, newRegistrationError("invalid_token", "a valid bearer token is required")) } func (s *Server) writeRegistrationForbidden(ctx context.Context, w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope"`) s.writeRegistrationJSON(ctx, w, http.StatusForbidden, newRegistrationError("insufficient_scope", "the access token is not allowed to register clients")) } // writeRegistrationServerError answers a 500 for both the registration endpoint and the // management endpoints, so like the 401 above the description is kept operation-neutral. The // cause is logged rather than returned, as it is not the client's to act on. func (s *Server) writeRegistrationServerError(ctx context.Context, w http.ResponseWriter, err error) { s.getLogger(ctx).ErrorContext(ctx, "dynamic client registration", "err", err) s.writeRegistrationJSON(ctx, w, http.StatusInternalServerError, newRegistrationError("server_error", "the request could not be processed")) } func (s *Server) writeRegistrationCommandError(ctx context.Context, w http.ResponseWriter, err error) { if zerrors.IsErrorInvalidArgument(err) { s.writeRegistrationError(ctx, w, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported")) return } s.writeRegistrationServerError(ctx, w, err) } // OAuth 2.0 Dynamic Client Registration error codes as defined in RFC 7591 §3.2.2. const ( registrationErrorInvalidRedirectURI = "invalid_redirect_uri" registrationErrorInvalidClientMetadata = "invalid_client_metadata" ) // registrationError is an OAuth 2.0 Dynamic Client Registration error response // (RFC 7591 §3.2.2). Invalid client metadata is returned with HTTP status 400; the same // shape is reused for the 401 (invalid_token) and 500 (server_error) responses. type registrationError struct { ErrorType string `json:"error"` ErrorDescription string `json:"error_description,omitempty"` } func (e *registrationError) Error() string { if e.ErrorDescription == "" { return e.ErrorType } return e.ErrorType + ": " + e.ErrorDescription } func newRegistrationError(errorType, description string) *registrationError { return ®istrationError{ErrorType: errorType, ErrorDescription: description} } // clientRegistrationRequest holds the client metadata of an OAuth 2.0 Dynamic Client // Registration request (RFC 7591 §2, extended by OpenID Connect Dynamic Client // Registration 1.0 §2). Only the members relevant for a ZITADEL OIDC application are // interpreted. Free-form metadata (client_uri, logo_uri, contacts, etc.) is accepted but // not persisted in this version. Members that imply unsupported behaviour (jwks/jwks_uri, // private_key_jwt) are rejected explicitly. type clientRegistrationRequest struct { RedirectURIs []string `json:"redirect_uris,omitempty"` ResponseTypes []string `json:"response_types,omitempty"` GrantTypes []string `json:"grant_types,omitempty"` ApplicationType string `json:"application_type,omitempty"` ClientName string `json:"client_name,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` JWKsURI string `json:"jwks_uri,omitempty"` JWKs json.RawMessage `json:"jwks,omitempty"` } // clientRegistrationResponse is the client information response of a successful // registration (RFC 7591 §3.2.1) and of the RFC 7592 read and update operations. type clientRegistrationResponse struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret,omitempty"` ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"` // RegistrationAccessToken and RegistrationClientURI let the client manage its own // registration (RFC 7592 §3). The token is only returned when it is (re)issued, i.e. on // registration and on update; a read does not rotate it and therefore omits it. RegistrationAccessToken string `json:"registration_access_token,omitempty"` RegistrationClientURI string `json:"registration_client_uri,omitempty"` RedirectURIs []string `json:"redirect_uris,omitempty"` ResponseTypes []string `json:"response_types,omitempty"` GrantTypes []string `json:"grant_types,omitempty"` ApplicationType string `json:"application_type,omitempty"` ClientName string `json:"client_name,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris,omitempty"` } // toOIDCApp maps the registration request to a domain OIDC application, applying the RFC // 7591 defaults and rejecting unsupported metadata. The returned application is not yet // persisted; the application name and the client credentials are assigned by the command. func (req *clientRegistrationRequest) toOIDCApp() (*domain.OIDCApp, *registrationError) { if req.JWKsURI != "" || len(req.JWKs) > 0 { return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "jwks and jwks_uri are not supported") } redirectURIs := trimSpaceSlice(req.RedirectURIs) if len(redirectURIs) == 0 { return nil, newRegistrationError(registrationErrorInvalidRedirectURI, "at least one redirect_uri is required") } applicationType, regErr := registrationApplicationTypeToDomain(req.ApplicationType, redirectURIs) if regErr != nil { return nil, regErr } authMethod, regErr := registrationAuthMethodToDomain(req.TokenEndpointAuthMethod, applicationType) if regErr != nil { return nil, regErr } grantTypes, regErr := registrationGrantTypesToDomain(req.GrantTypes) if regErr != nil { return nil, regErr } responseTypes, regErr := registrationResponseTypesToDomain(req.ResponseTypes) if regErr != nil { return nil, regErr } app := &domain.OIDCApp{ AppName: strings.TrimSpace(req.ClientName), RedirectUris: redirectURIs, ResponseTypes: responseTypes, GrantTypes: grantTypes, ApplicationType: gu.Ptr(applicationType), AuthMethodType: gu.Ptr(authMethod), PostLogoutRedirectUris: trimSpaceSlice(req.PostLogoutRedirectURIs), OIDCVersion: gu.Ptr(domain.OIDCVersionV1), AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), } if !app.IsValid() { return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "the requested grant and response type combination is not supported") } if compliance := domain.GetOIDCV1Compliance(app.ApplicationType, app.GrantTypes, app.AuthMethodType, app.RedirectUris); compliance.NoneCompliant { return nil, registrationComplianceError(compliance) } return app, nil } // registrationComplianceError maps a domain compliance failure to the matching RFC 7591 // error. Auth-method and grant/response combination problems are client metadata errors; // redirect URI scheme problems are reported as an invalid redirect URI. The internal // compliance keys (see internal/domain/application_oidc.go) are not exposed to the client. func registrationComplianceError(compliance *domain.Compliance) *registrationError { for _, problem := range compliance.Problems { if strings.Contains(problem, "AuthMethodType") || strings.Contains(problem, "GrantType") || strings.Contains(problem, "Combinations") { return newRegistrationError(registrationErrorInvalidClientMetadata, "the requested client metadata is not supported") } } return newRegistrationError(registrationErrorInvalidRedirectURI, "one or more redirect_uris are invalid for the requested application type") } func registrationAuthMethodToDomain(method string, applicationType domain.OIDCApplicationType) (domain.OIDCAuthMethodType, *registrationError) { switch method { case "": // Default the auth method to the application type. Native and user-agent // applications must be public (none); web applications default to // client_secret_basic per RFC 7591 §2. if applicationType == domain.OIDCApplicationTypeNative || applicationType == domain.OIDCApplicationTypeUserAgent { return domain.OIDCAuthMethodTypeNone, nil } return domain.OIDCAuthMethodTypeBasic, nil case "client_secret_basic": return domain.OIDCAuthMethodTypeBasic, nil case "client_secret_post": return domain.OIDCAuthMethodTypePost, nil case "none": return domain.OIDCAuthMethodTypeNone, nil default: return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "token_endpoint_auth_method "+method+" is not supported") } } func registrationGrantTypesToDomain(grantTypes []string) ([]domain.OIDCGrantType, *registrationError) { if len(grantTypes) == 0 { // RFC 7591 §2: authorization_code is the default. return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, nil } mapped := make([]domain.OIDCGrantType, 0, len(grantTypes)) for _, grantType := range grantTypes { switch grantType { case "authorization_code": mapped = append(mapped, domain.OIDCGrantTypeAuthorizationCode) case "refresh_token": mapped = append(mapped, domain.OIDCGrantTypeRefreshToken) case "implicit": mapped = append(mapped, domain.OIDCGrantTypeImplicit) case "urn:ietf:params:oauth:grant-type:device_code": mapped = append(mapped, domain.OIDCGrantTypeDeviceCode) case "urn:ietf:params:oauth:grant-type:token-exchange": mapped = append(mapped, domain.OIDCGrantTypeTokenExchange) default: return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "grant_type "+grantType+" is not supported") } } return mapped, nil } func registrationResponseTypesToDomain(responseTypes []string) ([]domain.OIDCResponseType, *registrationError) { if len(responseTypes) == 0 { // RFC 7591 §2: code is the default. return []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, nil } mapped := make([]domain.OIDCResponseType, 0, len(responseTypes)) for _, responseType := range responseTypes { switch normalizeResponseType(responseType) { case "code": mapped = append(mapped, domain.OIDCResponseTypeCode) case "id_token": mapped = append(mapped, domain.OIDCResponseTypeIDToken) case "id_token token": mapped = append(mapped, domain.OIDCResponseTypeIDTokenToken) default: return nil, newRegistrationError(registrationErrorInvalidClientMetadata, "response_type "+responseType+" is not supported") } } return mapped, nil } // normalizeResponseType sorts the space separated values of a response_type so that the // order of "token" and "id_token" does not matter. func normalizeResponseType(responseType string) string { fields := strings.Fields(responseType) hasToken, hasIDToken, hasCode := false, false, false for _, field := range fields { switch field { case "token": hasToken = true case "id_token": hasIDToken = true case "code": hasCode = true } } switch { case hasCode && !hasToken && !hasIDToken: return "code" case hasIDToken && hasToken && !hasCode: return "id_token token" case hasIDToken && !hasToken && !hasCode: return "id_token" default: return strings.Join(fields, " ") } } // registrationApplicationTypeToDomain resolves the application type of a registration // request. An explicit application_type is always honoured; it is only inferred when the // member is absent. // // OpenID Connect Dynamic Client Registration 1.0 §2 defaults to web, but application_type is // an OpenID Connect member: clients that only implement RFC 7591, which does not define it, // omit it. Native hosts doing so (Cursor and other MCP clients) register custom-scheme // redirect URIs, which ZITADEL reserves for native applications, so the web default would // reject them for a redirect URI that is in fact valid for what they are. Infer native in // exactly that case; everything that is accepted today keeps its current type. func registrationApplicationTypeToDomain(applicationType string, redirectURIs []string) (domain.OIDCApplicationType, *registrationError) { switch applicationType { case "": if domain.OIDCRedirectURIsRequireNative(redirectURIs) { return domain.OIDCApplicationTypeNative, nil } return domain.OIDCApplicationTypeWeb, nil case "web": return domain.OIDCApplicationTypeWeb, nil case "native": return domain.OIDCApplicationTypeNative, nil default: return 0, newRegistrationError(registrationErrorInvalidClientMetadata, "application_type "+applicationType+" is not supported") } } // newClientRegistrationResponse builds the RFC 7591 §3.2.1 response from the registered // application. The client name is echoed from the request, as the human-readable name is // not persisted as-is in this version. func newClientRegistrationResponse(app *domain.OIDCApp, clientName string, issuedAt int64) *clientRegistrationResponse { resp := &clientRegistrationResponse{ ClientID: app.ClientID, ClientSecret: app.ClientSecretString, ClientIDIssuedAt: issuedAt, RedirectURIs: app.RedirectUris, ResponseTypes: responseTypesToRegistration(app.ResponseTypes), GrantTypes: grantTypesToRegistration(app.GrantTypes), ApplicationType: applicationTypeToRegistration(gu.Value(app.ApplicationType)), ClientName: strings.TrimSpace(clientName), TokenEndpointAuthMethod: authMethodToRegistration(gu.Value(app.AuthMethodType)), PostLogoutRedirectURIs: app.PostLogoutRedirectUris, } if app.ClientSecretString != "" { // RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED if client_secret is // issued. 0 means the secret never expires. resp.ClientSecretExpiresAt = gu.Ptr(int64(0)) } return resp } func authMethodToRegistration(authMethod domain.OIDCAuthMethodType) string { switch authMethod { case domain.OIDCAuthMethodTypeBasic: return "client_secret_basic" case domain.OIDCAuthMethodTypePost: return "client_secret_post" case domain.OIDCAuthMethodTypeNone: return "none" case domain.OIDCAuthMethodTypePrivateKeyJWT: return "private_key_jwt" default: return "" } } func grantTypesToRegistration(grantTypes []domain.OIDCGrantType) []string { mapped := make([]string, 0, len(grantTypes)) for _, grantType := range grantTypes { switch grantType { case domain.OIDCGrantTypeAuthorizationCode: mapped = append(mapped, "authorization_code") case domain.OIDCGrantTypeRefreshToken: mapped = append(mapped, "refresh_token") case domain.OIDCGrantTypeImplicit: mapped = append(mapped, "implicit") case domain.OIDCGrantTypeDeviceCode: mapped = append(mapped, "urn:ietf:params:oauth:grant-type:device_code") case domain.OIDCGrantTypeTokenExchange: mapped = append(mapped, "urn:ietf:params:oauth:grant-type:token-exchange") } } return mapped } func responseTypesToRegistration(responseTypes []domain.OIDCResponseType) []string { mapped := make([]string, 0, len(responseTypes)) for _, responseType := range responseTypes { switch responseType { case domain.OIDCResponseTypeCode: mapped = append(mapped, "code") case domain.OIDCResponseTypeIDToken: mapped = append(mapped, "id_token") case domain.OIDCResponseTypeIDTokenToken: mapped = append(mapped, "id_token token") case domain.OIDCResponseTypeUnspecified: // not exposed through dynamic client registration } } return mapped } func applicationTypeToRegistration(applicationType domain.OIDCApplicationType) string { switch applicationType { case domain.OIDCApplicationTypeWeb: return "web" case domain.OIDCApplicationTypeNative: return "native" case domain.OIDCApplicationTypeUserAgent: return "user_agent" default: return "" } } func trimSpaceSlice(values []string) []string { if len(values) == 0 { return nil } trimmed := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { trimmed = append(trimmed, value) } } if len(trimmed) == 0 { return nil } return trimmed }