@@ -158,12 +158,22 @@ public function fromRegistrationData(
158158 unset($ metadata [$ adminOnlyMetadataKey ]);
159159 }
160160
161+ // RFC 7592 client update is a full REPLACE, not a merge: on a DCR update, client-settable metadata the
162+ // request omits must be reset to its OP default (or removed), while server-managed and admin-only
163+ // properties are still carried over from the existing client. We model that with a separate "metadata
164+ // fallback" client that is null on a DCR update, so the per-field `?? $metadataFallbackClient?->...`
165+ // expressions below fall back to the default rather than the previously-registered value. Manual and
166+ // OpenID Federation registrations keep their existing merge behaviour (the entity statement / admin form
167+ // carries the full intended state anyway).
168+ $ isDcrUpdate = $ existingClient !== null && $ registrationType === RegistrationTypeEnum::Dynamic;
169+ $ metadataFallbackClient = $ isDcrUpdate ? null : $ existingClient ;
170+
161171 $ id = $ clientIdentifier ?? $ existingClient ?->getIdentifier() ??
162172 $ this ->sspBridge ->utils ()->random ()->generateID ();
163173
164174 $ secret = $ existingClient ?->getSecret() ?? $ this ->sspBridge ->utils ()->random ()->generateID ();
165175
166- $ name = (string )($ metadata [ClaimsEnum::ClientName->value ] ?? $ existingClient ?->getName() ?? $ id );
176+ $ name = (string )($ metadata [ClaimsEnum::ClientName->value ] ?? $ metadataFallbackClient ?->getName() ?? $ id );
167177
168178 $ description = $ existingClient ?->getDescription() ?? '' ;
169179
@@ -175,7 +185,7 @@ public function fromRegistrationData(
175185
176186 // Resolve the requested scopes: from this request's metadata, falling back to an existing client's scopes
177187 // (e.g. on a DCR update that omits `scope`). null here means scopes were genuinely not specified.
178- $ requestedScopes = $ metadata [ClaimsEnum::Scope->value ] ?? $ existingClient ?->getScopes();
188+ $ requestedScopes = $ metadata [ClaimsEnum::Scope->value ] ?? $ metadataFallbackClient ?->getScopes();
179189 if ($ requestedScopes === null ) {
180190 // No scope was specified. For Dynamic Client Registration, assign the configured default scope set
181191 // (OIDC DCR 1.0 lets the OP assign a default set). Manual and OpenID Federation automatic registrations
@@ -205,7 +215,7 @@ public function fromRegistrationData(
205215 ? $ this ->moduleConfig ->getDcrRegisteredClientsEnabled ()
206216 : true );
207217
208- $ isConfidential = $ existingClient ?->isConfidential() ?? $ this ->determineIsConfidential (
218+ $ isConfidential = $ metadataFallbackClient ?->isConfidential() ?? $ this ->determineIsConfidential (
209219 $ metadata ,
210220 );
211221
@@ -214,39 +224,39 @@ public function fromRegistrationData(
214224 $ postLogoutRedirectUris = isset ($ metadata [ClaimsEnum::PostLogoutRedirectUris->value ]) &&
215225 is_array ($ metadata [ClaimsEnum::PostLogoutRedirectUris->value ]) ?
216226 $ this ->helpers ->arr ()->ensureStringValues ($ metadata [ClaimsEnum::PostLogoutRedirectUris->value ]) :
217- $ existingClient ?->getPostLogoutRedirectUri() ?? [];
227+ $ metadataFallbackClient ?->getPostLogoutRedirectUri() ?? [];
218228
219229 $ backChannelLogoutUri = isset ($ metadata [ClaimsEnum::BackChannelLogoutUri->value ]) &&
220230 is_string ($ metadata [ClaimsEnum::BackChannelLogoutUri->value ]) ?
221231 $ metadata [ClaimsEnum::BackChannelLogoutUri->value ] :
222- $ existingClient ?->getBackChannelLogoutUri();
232+ $ metadataFallbackClient ?->getBackChannelLogoutUri();
223233
224234 $ entityIdentifier = $ clientIdentifier ?? $ existingClient ?->getEntityIdentifier();
225235
226236 $ clientRegistrationTypes = isset ($ metadata [ClaimsEnum::ClientRegistrationTypes->value ]) &&
227237 is_array ($ metadata [ClaimsEnum::ClientRegistrationTypes->value ]) ?
228238 $ this ->helpers ->arr ()->ensureStringValues ($ metadata [ClaimsEnum::ClientRegistrationTypes->value ]) :
229- $ existingClient ?->getClientRegistrationTypes();
239+ $ metadataFallbackClient ?->getClientRegistrationTypes();
230240
231- $ federationJwks = $ federationJwks ?? $ existingClient ?->getFederationJwks();
241+ $ federationJwks = $ federationJwks ?? $ metadataFallbackClient ?->getFederationJwks();
232242
233243 /** @var ?array[] $jwks */
234244 $ jwks = isset ($ metadata [ClaimsEnum::Jwks->value ]) &&
235245 is_array ($ metadata [ClaimsEnum::Jwks->value ]) &&
236246 array_key_exists (ClaimsEnum::Keys->value , $ metadata [ClaimsEnum::Jwks->value ]) &&
237247 (!empty ($ metadata [ClaimsEnum::Jwks->value ][ClaimsEnum::Keys->value ])) ?
238248 $ metadata [ClaimsEnum::Jwks->value ] :
239- $ existingClient ?->getJwks();
249+ $ metadataFallbackClient ?->getJwks();
240250
241251 $ jwksUri = isset ($ metadata [ClaimsEnum::JwksUri->value ]) &&
242252 is_string ($ metadata [ClaimsEnum::JwksUri->value ]) ?
243253 $ metadata [ClaimsEnum::JwksUri->value ] :
244- $ existingClient ?->getJwksUri();
254+ $ metadataFallbackClient ?->getJwksUri();
245255
246256 $ signedJwksUri = isset ($ metadata [ClaimsEnum::SignedJwksUri->value ]) &&
247257 is_string ($ metadata [ClaimsEnum::SignedJwksUri->value ]) ?
248258 $ metadata [ClaimsEnum::SignedJwksUri->value ] :
249- $ existingClient ?->getSignedJwksUri();
259+ $ metadataFallbackClient ?->getSignedJwksUri();
250260
251261// $registrationType = $registrationType;
252262
@@ -262,14 +272,27 @@ public function fromRegistrationData(
262272 // is null here; the registration controller generates and assigns the token after building the entity.
263273 $ registrationAccessToken = $ existingClient ?->getRegistrationAccessTokenHash();
264274
265- $ extraMetadata = $ existingClient ?->getExtraMetadata() ?? [];
275+ // On a DCR update this starts empty (replace semantics); on create/manual/federation it carries the existing
276+ // extra metadata. Admin-only extra metadata (e.g. authproc) is never client-settable and is re-injected from
277+ // the real existing client below so a DCR update cannot drop it.
278+ $ extraMetadata = $ metadataFallbackClient ?->getExtraMetadata() ?? [];
279+ if ($ isDcrUpdate ) {
280+ // $isDcrUpdate implies $existingClient is non-null (see its definition above).
281+ $ existingExtraMetadata = $ existingClient ->getExtraMetadata ();
282+ foreach (ClientEntity::ADMIN_ONLY_METADATA_KEYS as $ adminOnlyMetadataKey ) {
283+ if (array_key_exists ($ adminOnlyMetadataKey , $ existingExtraMetadata )) {
284+ /** @psalm-suppress MixedAssignment */
285+ $ extraMetadata [$ adminOnlyMetadataKey ] = $ existingExtraMetadata [$ adminOnlyMetadataKey ];
286+ }
287+ }
288+ }
266289
267290 // Handle any other supported client metadata as extra metadata.
268291 // id_token_signed_response_alg
269292 $ idTokenSignedResponseAlg = isset ($ metadata [ClaimsEnum::IdTokenSignedResponseAlg->value ]) &&
270293 is_string ($ metadata [ClaimsEnum::IdTokenSignedResponseAlg->value ]) ?
271294 $ metadata [ClaimsEnum::IdTokenSignedResponseAlg->value ] :
272- $ existingClient ?->getIdTokenSignedResponseAlg();
295+ $ metadataFallbackClient ?->getIdTokenSignedResponseAlg();
273296
274297 // Make sure the requested id_token_signed_response_alg is one of the OP
275298 // can actually sign ID Tokens with, i.e. one for which a protocol
0 commit comments