mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 14:34:25 +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.
579 lines
24 KiB
Go
579 lines
24 KiB
Go
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
|
|
}
|