From 362fb8b193afc124281457f6d5bcd3f1a637ab0d Mon Sep 17 00:00:00 2001 From: Bradley Fuller Date: Wed, 22 Apr 2026 11:45:04 +1000 Subject: [PATCH 1/2] fix: retain existing RegistrationAccessTokenSignature when PATCHing a client patchOAuth2Client applies the JSON patch by marshaling the Client struct to JSON, applying patch operations, then unmarshaling back. The RegistrationAccessTokenSignature field is tagged json:"-", so it is excluded from both the marshal and unmarshal steps. The resulting struct has an empty signature, which is then persisted to the database via UpdateClient, permanently overwriting the stored signature with an empty string. Fixes ory#4093 # Conflicts: # client/handler.go --- client/handler.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/handler.go b/client/handler.go index 92b8555942..99e6f6b29a 100644 --- a/client/handler.go +++ b/client/handler.go @@ -446,6 +446,10 @@ func (h *Handler) patchOAuth2Client(w http.ResponseWriter, r *http.Request) { oldSecret := client.Secret + // RegistrationAccessTokenSignature is lost when the client is marshalled to JSON + // Store it prior to the patch and re-add it later + oldRegistrationAccessTokenSig := client.RegistrationAccessTokenSignature + client, err = jsonx.ApplyJSONPatch(patchJSON, client, "/id", "/RotatedSecrets", "rotated_secrets") if err != nil { h.r.Writer().WriteError(w, r, err) @@ -462,6 +466,9 @@ func (h *Handler) patchOAuth2Client(w http.ResponseWriter, r *http.Request) { client.RotatedSecrets = []string{} // explicitly clear } + // Re-add the registration access token signature before updating the client in DB + client.RegistrationAccessTokenSignature = oldRegistrationAccessTokenSig + if err := h.updateClient(r.Context(), client, h.r.ClientValidator().Validate); err != nil { h.r.Writer().WriteError(w, r, err) return From e50d602a6677ec18ab98a4185568bb0277fa5ed6 Mon Sep 17 00:00:00 2001 From: Bradley Fuller Date: Wed, 22 Apr 2026 12:07:49 +1000 Subject: [PATCH 2/2] fix: test patch preserves registration access token --- client/sdk_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/sdk_test.go b/client/sdk_test.go index cab53c4579..054afd278e 100644 --- a/client/sdk_test.go +++ b/client/sdk_test.go @@ -208,6 +208,27 @@ func TestClientSDK(t *testing.T) { assertx.EqualAsJSONExcept(t, expected, result, nil) }) + t.Run("case=patch preserves registration access token", func(t *testing.T) { + created, _, err := c.OAuth2API.CreateOAuth2Client(context.Background()).OAuth2Client(createTestClient("")).Execute() + require.NoError(t, err) + require.NotNil(t, created.RegistrationAccessToken) + originalRAT := *created.RegistrationAccessToken + + _, _, err = c.OAuth2API.PatchOAuth2Client(context.Background(), *created.ClientId). + JsonPatch([]hydra.JsonPatch{{Op: "replace", Path: "/client_uri", Value: "http://foo.bar"}}).Execute() + require.NoError(t, err) + + dynURL := publicServer.URL + client.DynClientsHandlerPath + "/" + *created.ClientId + + req, err := http.NewRequest(http.MethodGet, dynURL, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+originalRAT) + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode, "registration access token must still be valid after admin PATCH") + }) + t.Run("case=patch client illegally", func(t *testing.T) { op := "replace" path := "/id"