From 0fc2d90e4e61564714d87ec9bcca008e08a9765a 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 --- client/handler.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/handler.go b/client/handler.go index 79e55beb15..515bd58934 100644 --- a/client/handler.go +++ b/client/handler.go @@ -448,6 +448,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") 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.Secret = "" } + // 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 9afd4f255870abea74199b38444cdca5d9e3cf23 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 536b551533..05be9743ae 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"