From c4181d29265d5050da05462b296661c8e117929e Mon Sep 17 00:00:00 2001 From: Vicky Date: Fri, 22 May 2026 15:39:45 +0200 Subject: [PATCH] feat: optional access token strategy override for DCR Adds an optional configuration path `oidc.dynamic_client_registration.strategies.access_token` (`jwt` or `opaque`) that, when set, overrides the global `strategies.access_token` for clients created through OpenID Connect Dynamic Client Registration (RFC 7591). When unset, behaviour is unchanged: DCR-created clients inherit the global access token strategy at token-issuance time. Clients still cannot set `access_token_strategy` in the registration payload; the override is server-side only. The Admin API is unaffected. Closes https://github.com/ory/hydra/issues/4060 Co-authored-by: Cursor --- .schema/config.schema.json | 12 ++++++ client/validator.go | 7 ++++ client/validator_test.go | 82 ++++++++++++++++++++++++++++++++++++++ driver/config/provider.go | 19 +++++++++ spec/config.json | 12 ++++++ 5 files changed, 132 insertions(+) diff --git a/.schema/config.schema.json b/.schema/config.schema.json index bc36545029..4aa4aa42a8 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -462,6 +462,18 @@ "type": "string" }, "examples": [["openid", "offline", "offline_access"]] + }, + "strategies": { + "type": "object", + "additionalProperties": false, + "description": "Optional strategy overrides for clients created via OpenID Connect Dynamic Client Registration. When unset, dynamically registered clients inherit the global strategies (e.g. `strategies.access_token`).", + "properties": { + "access_token": { + "type": "string", + "enum": ["opaque", "jwt"], + "description": "Access token strategy used for clients created via Dynamic Client Registration. When unset, the global `strategies.access_token` is used. Clients cannot override this value via the registration payload." + } + } } } } diff --git a/client/validator.go b/client/validator.go index 0ffc95e6b9..a97a031656 100644 --- a/client/validator.go +++ b/client/validator.go @@ -235,6 +235,13 @@ func (v *Validator) ValidateDynamicRegistration(ctx context.Context, c *Client) return errors.WithStack(ErrInvalidRequest.WithDescription(`"skip_logout_consent" cannot be set for dynamic client registration`)) } + // Apply the configured access-token strategy override for dynamically + // registered clients (if any). When unset, the client inherits the + // global `strategies.access_token` setting at token-issuance time. + if s := v.r.Config().OIDCDynamicClientRegistrationAccessTokenStrategy(ctx); s != "" { + c.AccessTokenStrategy = string(s) + } + return v.Validate(ctx, c) } diff --git a/client/validator_test.go b/client/validator_test.go index bd60978ef7..828bd0c0ed 100644 --- a/client/validator_test.go +++ b/client/validator_test.go @@ -404,3 +404,85 @@ func TestValidateDynamicRegistration(t *testing.T) { }) } } + +// TestValidateDynamicRegistrationAccessTokenStrategy covers the behaviour +// matrix described in https://github.com/ory/hydra/issues/4060 for the +// optional `oidc.dynamic_client_registration.strategies.access_token` +// override. +func TestValidateDynamicRegistrationAccessTokenStrategy(t *testing.T) { + ctx := t.Context() + + baseConfig := func(extra map[string]any) map[string]any { + m := map[string]any{ + config.KeySubjectTypesSupported: []string{"public"}, + config.KeyDefaultClientScope: []string{"openid"}, + } + for k, v := range extra { + m[k] = v + } + return m + } + + for _, tc := range []struct { + name string + configValues map[string]any + in *Client + expectErr bool + expectStrategy string + }{ + { + name: "dcr-unset-global-opaque-client-inherits", + configValues: baseConfig(map[string]any{ + config.KeyAccessTokenStrategy: "opaque", + }), + in: &Client{RedirectURIs: []string{"https://foo/"}}, + expectStrategy: "", + }, + { + name: "dcr-unset-global-jwt-client-inherits", + configValues: baseConfig(map[string]any{ + config.KeyAccessTokenStrategy: "jwt", + }), + in: &Client{RedirectURIs: []string{"https://foo/"}}, + expectStrategy: "", + }, + { + name: "dcr-opaque-overrides-global-jwt", + configValues: baseConfig(map[string]any{ + config.KeyAccessTokenStrategy: "jwt", + config.KeyDCRAccessTokenStrategy: "opaque", + }), + in: &Client{RedirectURIs: []string{"https://foo/"}}, + expectStrategy: "opaque", + }, + { + name: "dcr-jwt-overrides-global-opaque", + configValues: baseConfig(map[string]any{ + config.KeyAccessTokenStrategy: "opaque", + config.KeyDCRAccessTokenStrategy: "jwt", + }), + in: &Client{RedirectURIs: []string{"https://foo/"}}, + expectStrategy: "jwt", + }, + { + name: "client-supplied-strategy-still-rejected-even-with-dcr-override", + configValues: baseConfig(map[string]any{ + config.KeyDCRAccessTokenStrategy: "jwt", + }), + in: &Client{RedirectURIs: []string{"https://foo/"}, AccessTokenStrategy: "jwt"}, + expectErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + reg := testhelpers.NewRegistryMemory(t, driver.WithConfigOptions(configx.WithValues(tc.configValues))) + v := NewValidator(reg) + err := v.ValidateDynamicRegistration(ctx, tc.in) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectStrategy, tc.in.AccessTokenStrategy) + }) + } +} diff --git a/driver/config/provider.go b/driver/config/provider.go index 737c95e5c4..16f4b9f2f1 100644 --- a/driver/config/provider.go +++ b/driver/config/provider.go @@ -52,6 +52,7 @@ const ( KeyOAuth2DeviceAuthorisationURL = "webfinger.oidc_discovery.device_authorization_url" KeySubjectTypesSupported = "oidc.subject_identifiers.supported_types" KeyDefaultClientScope = "oidc.dynamic_client_registration.default_scope" + KeyDCRAccessTokenStrategy = "oidc.dynamic_client_registration.strategies.access_token" // #nosec G101 KeyDSN = "dsn" KeyClientHTTPNoPrivateIPRanges = "clients.http.disallow_private_ip_ranges" KeyClientHTTPPrivateIPExceptionURLs = "clients.http.private_ip_exception_urls" @@ -598,6 +599,24 @@ func (p *DefaultProvider) AccessTokenStrategy(ctx context.Context, additionalSou return s } +// OIDCDynamicClientRegistrationAccessTokenStrategy returns the access token +// strategy that should be applied to clients created via OpenID Connect +// Dynamic Client Registration (RFC 7591). An empty string means the option +// is not configured and DCR clients should fall back to the global +// `strategies.access_token` setting. +func (p *DefaultProvider) OIDCDynamicClientRegistrationAccessTokenStrategy(ctx context.Context) AccessTokenStrategyType { + raw := p.getProvider(ctx).String(KeyDCRAccessTokenStrategy) + if raw == "" { + return "" + } + s, err := ToAccessTokenStrategyType(raw) + if err != nil { + p.l.WithError(err).Warnf("Key `%s` contains an invalid value, falling back to the global access token strategy.", KeyDCRAccessTokenStrategy) + return "" + } + return s +} + type ( Auth struct { Type string `json:"type"` diff --git a/spec/config.json b/spec/config.json index d4e0ca7b16..6f15eef865 100644 --- a/spec/config.json +++ b/spec/config.json @@ -462,6 +462,18 @@ "type": "string" }, "examples": [["openid", "offline", "offline_access"]] + }, + "strategies": { + "type": "object", + "additionalProperties": false, + "description": "Optional strategy overrides for clients created via OpenID Connect Dynamic Client Registration. When unset, dynamically registered clients inherit the global strategies (e.g. `strategies.access_token`).", + "properties": { + "access_token": { + "type": "string", + "enum": ["opaque", "jwt"], + "description": "Access token strategy used for clients created via Dynamic Client Registration. When unset, the global `strategies.access_token` is used. Clients cannot override this value via the registration payload." + } + } } } }