From cb2c67b295b2ad222143c299f412c1bad81d77ac Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Wed, 1 Apr 2026 18:30:02 +0200 Subject: [PATCH 01/10] feat: allow custom scope claim name --- router-tests/security/authentication_test.go | 51 +++++++++++++++++++ router/core/access_controller.go | 4 ++ router/core/ratelimiter_test.go | 3 ++ router/core/supervisor_instance.go | 1 + router/pkg/authentication/authentication.go | 28 ++++++++-- router/pkg/config/config.go | 1 + router/pkg/config/config.schema.json | 5 ++ router/pkg/config/config_test.go | 35 +++++++++++++ router/pkg/config/fixtures/full.yaml | 1 + .../pkg/config/testdata/config_defaults.json | 1 + router/pkg/config/testdata/config_full.json | 1 + 11 files changed, 128 insertions(+), 3 deletions(-) diff --git a/router-tests/security/authentication_test.go b/router-tests/security/authentication_test.go index 7cbfbe3122..8894d1fa9a 100644 --- a/router-tests/security/authentication_test.go +++ b/router-tests/security/authentication_test.go @@ -606,6 +606,57 @@ func TestAuthentication(t *testing.T) { require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) }) }) + t.Run("scopes required valid token from configured custom scope claim", func(t *testing.T) { + t.Parallel() + + authServer, err := jwks.NewServer(t) + require.NoError(t, err) + t.Cleanup(authServer.Close) + + tokenDecoder, err := authentication.NewJwksTokenDecoder( + testutils.NewContextWithCancel(t), + zap.NewNop(), + []authentication.JWKSConfig{toJWKSConfig(authServer.JWKSURL(), time.Second*5)}, + ) + require.NoError(t, err) + + authenticator, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ + Name: testutils.JwksName, + TokenDecoder: tokenDecoder, + }) + require.NoError(t, err) + + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: []authentication.Authenticator{authenticator}, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + ScopeClaim: "scp", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + token, err := authServer.Token(map[string]any{ + "scp": "read:employee read:private", + }) + require.NoError(t, err) + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, testutils.JwksName, res.Header.Get(xAuthenticatedByHeader)) + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) + }) + }) t.Run("scopes required valid token AND scopes present with alias", func(t *testing.T) { t.Parallel() diff --git a/router/core/access_controller.go b/router/core/access_controller.go index 27d1000cb3..d09f0dc670 100644 --- a/router/core/access_controller.go +++ b/router/core/access_controller.go @@ -22,6 +22,7 @@ type AccessControllerOptions struct { AuthenticationRequired bool SkipIntrospectionQueries bool IntrospectionSkipSecret string + ScopeClaim string } // AccessController handles both authentication and authorization for the Router @@ -30,6 +31,7 @@ type AccessController struct { authenticators []authentication.Authenticator skipIntrospectionQueries bool introspectionSkipSecret string + scopeClaim string } // NewAccessController creates a new AccessController. @@ -40,6 +42,7 @@ func NewAccessController(opts AccessControllerOptions) (*AccessController, error skipIntrospectionQueries: opts.SkipIntrospectionQueries, authenticators: opts.Authenticators, introspectionSkipSecret: opts.IntrospectionSkipSecret, + scopeClaim: opts.ScopeClaim, }, nil } @@ -52,6 +55,7 @@ func (a *AccessController) Access(w http.ResponseWriter, r *http.Request) (*http return nil, errors.Join(err, ErrUnauthorized) } if auth != nil { + auth.SetScopesClaim(a.scopeClaim) w.Header().Set("X-Authenticated-By", auth.Authenticator()) return r.WithContext(authentication.NewContext(r.Context(), auth)), nil } diff --git a/router/core/ratelimiter_test.go b/router/core/ratelimiter_test.go index bae2b20689..b3ade9594d 100644 --- a/router/core/ratelimiter_test.go +++ b/router/core/ratelimiter_test.go @@ -182,6 +182,9 @@ func (f *FakeAuthenticator) Claims() authentication.Claims { return f.claims } +func (f *FakeAuthenticator) SetScopesClaim(scopeClaim string) { +} + func (f *FakeAuthenticator) SetScopes(scopes []string) { //TODO implement me panic("implement me") diff --git a/router/core/supervisor_instance.go b/router/core/supervisor_instance.go index 2f9f6fcbfb..1fd8eb8acb 100644 --- a/router/core/supervisor_instance.go +++ b/router/core/supervisor_instance.go @@ -64,6 +64,7 @@ func newRouter(ctx context.Context, params RouterResources, additionalOptions .. AuthenticationRequired: cfg.Authorization.RequireAuthentication, SkipIntrospectionQueries: cfg.Authentication.IgnoreIntrospection, IntrospectionSkipSecret: cfg.IntrospectionConfig.Secret, + ScopeClaim: cfg.Authentication.JWT.ScopeClaim, }) if err != nil { return nil, fmt.Errorf("could not create access controller: %w", err) diff --git a/router/pkg/authentication/authentication.go b/router/pkg/authentication/authentication.go index c08906ab09..6923cc3235 100644 --- a/router/pkg/authentication/authentication.go +++ b/router/pkg/authentication/authentication.go @@ -9,6 +9,8 @@ import ( type Claims map[string]any +const DefaultScopeClaim = "scope" + // Provider is an interface that represents entities that might provide // authentication information. If no authentication information is available, // the AuthenticationHeaders method should return nil. @@ -31,6 +33,8 @@ type Authentication interface { // Claims returns the claims of the authenticated request, as returned by // the Authenticator. Claims() Claims + // SetScopesClaim sets the claim key used by Scopes and SetScopes. + SetScopesClaim(scopeClaim string) // SetScopes sets the scopes of the authenticated request. It will replace the scopes already parsed from the claims. // If users desire to append the scopes, they can first run `Scopes` to get the current scopes, and then append the new scopes SetScopes(scopes []string) @@ -42,6 +46,7 @@ type Authentication interface { type authentication struct { authenticator string claims Claims + scopeClaim string } func (a *authentication) Authenticator() string { @@ -55,6 +60,13 @@ func (a *authentication) Claims() Claims { return a.claims } +func (a *authentication) SetScopesClaim(scopeClaim string) { + if a == nil { + return + } + a.scopeClaim = scopeClaim +} + func (a *authentication) SetScopes(scopes []string) { if a == nil { return @@ -63,20 +75,27 @@ func (a *authentication) SetScopes(scopes []string) { a.claims = make(Claims) } // per https://datatracker.ietf.org/doc/html/rfc8693#section-2.1-4.8, scopes should be space separated - a.claims["scope"] = strings.Join(scopes, " ") + a.claims[a.scopeClaimKey()] = strings.Join(scopes, " ") } func (a *authentication) Scopes() []string { if a == nil { return nil } - scopes, ok := a.claims["scope"].(string) + scopes, ok := a.claims[a.scopeClaimKey()].(string) if !ok { return nil } return strings.Split(scopes, " ") } +func (a *authentication) scopeClaimKey() string { + if a == nil || len(a.scopeClaim) == 0 { + return DefaultScopeClaim + } + return a.scopeClaim +} + var errUnacceptableAud = errors.New("audience match not found") // Authenticate tries to authenticate the given Provider using the given authenticators. If any of @@ -105,6 +124,7 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide return &authentication{ authenticator: auth.Name(), claims: claims, + scopeClaim: DefaultScopeClaim, }, nil } // If no authentication failed error will be nil here, @@ -113,5 +133,7 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide } func NewEmptyAuthentication() Authentication { - return &authentication{} + return &authentication{ + scopeClaim: DefaultScopeClaim, + } } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index d4f83e381f..72e0a7c07c 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -594,6 +594,7 @@ type HeaderSource struct { type JWTAuthenticationConfiguration struct { JWKS []JWKSConfiguration `yaml:"jwks"` + ScopeClaim string `yaml:"scope_claim" envDefault:"scope"` HeaderName string `yaml:"header_name" envDefault:"Authorization"` HeaderValuePrefix string `yaml:"header_value_prefix" envDefault:"Bearer"` HeaderSources []HeaderSource `yaml:"header_sources"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 32429f30ea..b5998aa2ae 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2102,6 +2102,11 @@ ] } }, + "scope_claim": { + "type": "string", + "description": "The JWT claim to use when reading scopes for authorization. The default value is 'scope'.", + "default": "scope" + }, "header_name": { "type": "string", "description": "The name of the header. The header is used to extract the token from the request. The default value is 'Authorization'.", diff --git a/router/pkg/config/config_test.go b/router/pkg/config/config_test.go index d93103fb9e..c96280ab6c 100644 --- a/router/pkg/config/config_test.go +++ b/router/pkg/config/config_test.go @@ -1410,6 +1410,41 @@ authentication: require.NoError(t, err) }) + t.Run("verify scope claim can be configured", func(t *testing.T) { + t.Parallel() + + f := createTempFileFromFixture(t, ` +version: "1" + +authentication: + jwt: + scope_claim: "scp" + jwks: + - url: "http://url/valid.json" + +`) + cfg, err := LoadConfig([]string{f}) + require.NoError(t, err) + require.Equal(t, "scp", cfg.Config.Authentication.JWT.ScopeClaim) + }) + + t.Run("verify scope claim defaults to scope", func(t *testing.T) { + t.Parallel() + + f := createTempFileFromFixture(t, ` +version: "1" + +authentication: + jwt: + jwks: + - url: "http://url/valid.json" + +`) + cfg, err := LoadConfig([]string{f}) + require.NoError(t, err) + require.Equal(t, "scope", cfg.Config.Authentication.JWT.ScopeClaim) + }) + t.Run("verify both secret and url are not allowed together", func(t *testing.T) { t.Parallel() diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index da15e859fe..b074445a99 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -321,6 +321,7 @@ authentication: - url: 'https://example.com/.well-known/jwks3.json' header_name: Authorization header_value_prefix: Bearer + scope_claim: customscp header_sources: - type: header name: X-Authorization diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 6afe8c00b1..b318651e6b 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -298,6 +298,7 @@ "Authentication": { "JWT": { "JWKS": null, + "ScopeClaim": "scope", "HeaderName": "Authorization", "HeaderValuePrefix": "Bearer", "HeaderSources": null diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index e09957bc29..9ffa75020e 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -602,6 +602,7 @@ "Audiences": null } ], + "ScopeClaim": "customscp", "HeaderName": "Authorization", "HeaderValuePrefix": "Bearer", "HeaderSources": [ From 74635d02c365f475b6b6177a1c492eab05e3c00e Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Wed, 1 Apr 2026 18:41:54 +0200 Subject: [PATCH 02/10] feat: add documentation about new config --- docs-website/router/authentication-and-authorization.mdx | 4 +++- docs-website/router/configuration.mdx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs-website/router/authentication-and-authorization.mdx b/docs-website/router/authentication-and-authorization.mdx index cd205f0004..d12f1d28d8 100644 --- a/docs-website/router/authentication-and-authorization.mdx +++ b/docs-website/router/authentication-and-authorization.mdx @@ -42,6 +42,7 @@ In the current router version, the configuration and behavior of authentication secret: header_key_id: some-key-id header_name: Authorization # Optional, Authorization is the default value + scope_claim: scope # Optional, scope is the default value header_value_prefix: Bearer # Optional, Bearer is the default value header_sources: - type: header @@ -54,6 +55,8 @@ In the current router version, the configuration and behavior of authentication The router configuration facilitates the setup of multiple JWKS (JSON Web Key Set) endpoints, each customizable with distinct retrieval settings. It allows specification of supported JWT (JSON Web Token) algorithms per endpoint. It also allows the use of secrets for symmetric algorithms, as it would be a security risk to expose them over a JWKS endpoint. Centralizing header rules application across all keys from every JWKS endpoint simplifies management. This setup grants centralized control while offering flexibility in the retrieval and processing of keys. +Use `scope_claim` when your provider stores authorization scopes in a claim other than `scope`, such as `scp` or `roles`. + For more information on the attributes, visit the auth configuration parameter section page [here](/router/configuration#authentication). ### Disabling Authentication for Introspection Operations @@ -176,4 +179,3 @@ If we send 6 simultaneous requests with unknown KIDs: The 2nd, 3rd and 4th requests are rate-limited because the burst capacity is set to 1. After the first request passes, the number of available burst tokens is 0 and subsequent requests must wait until the `interval` elapses. If `burst` were set to 2, the second request would also pass immediately. The `max_wait` setting prevents excessive wait times. In this example, since the 5th and 6th requests would require waiting longer than the configured `max_wait` of 110s, they immediately return with a 401 Unauthorized status instead of attempting to refresh. - diff --git a/docs-website/router/configuration.mdx b/docs-website/router/configuration.mdx index 1e5e4014b1..00c06b6e22 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -1566,6 +1566,7 @@ In addition to the above JWKS configuration flavours, you can define a list of a | YAML | Required | Description | Default Value | | ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------- | +| scope_claim | | The JWT claim used to read scopes for authorization. Use this when your identity provider stores scopes in a claim other than `scope`. | scope | | header_name | | The name of the header. The header is used to extract the token from the request. The default value is 'Authorization'. | Authorization | | header_value_prefix | | The prefix of the header value. The prefix is used to extract the token from the header value. The default value is 'Bearer'. | Bearer | @@ -1601,6 +1602,7 @@ authentication: interval: 30s burst: 2 header_name: Authorization # This is the default value + scope_claim: scope # This is the default value header_value_prefix: Bearer # This is the default value header_sources: - type: header From e1aaf9975222e2d0866d0ce2e01163916b93eaf8 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Wed, 1 Apr 2026 18:49:03 +0200 Subject: [PATCH 03/10] fix: force scope_claim min length --- router/pkg/config/config.schema.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index b5998aa2ae..c302f31995 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2105,7 +2105,8 @@ "scope_claim": { "type": "string", "description": "The JWT claim to use when reading scopes for authorization. The default value is 'scope'.", - "default": "scope" + "default": "scope", + "minLength": 1 }, "header_name": { "type": "string", From 79ed1ff44c4383bc62ac58770bcdbec68ce9ae54 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 7 Apr 2026 10:42:52 +0200 Subject: [PATCH 04/10] fix: avoid useless complexity --- router/pkg/authentication/authentication.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/router/pkg/authentication/authentication.go b/router/pkg/authentication/authentication.go index 6923cc3235..da8f4877be 100644 --- a/router/pkg/authentication/authentication.go +++ b/router/pkg/authentication/authentication.go @@ -75,27 +75,20 @@ func (a *authentication) SetScopes(scopes []string) { a.claims = make(Claims) } // per https://datatracker.ietf.org/doc/html/rfc8693#section-2.1-4.8, scopes should be space separated - a.claims[a.scopeClaimKey()] = strings.Join(scopes, " ") + a.claims[a.scopeClaim] = strings.Join(scopes, " ") } func (a *authentication) Scopes() []string { if a == nil { return nil } - scopes, ok := a.claims[a.scopeClaimKey()].(string) + scopes, ok := a.claims[a.scopeClaim].(string) if !ok { return nil } return strings.Split(scopes, " ") } -func (a *authentication) scopeClaimKey() string { - if a == nil || len(a.scopeClaim) == 0 { - return DefaultScopeClaim - } - return a.scopeClaim -} - var errUnacceptableAud = errors.New("audience match not found") // Authenticate tries to authenticate the given Provider using the given authenticators. If any of From d8065f7bc8d5e7e987d26dceb59704df509e84fa Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 7 Apr 2026 11:25:00 +0200 Subject: [PATCH 05/10] fix: set scope_claims only if different from an empty string --- router/core/access_controller.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/router/core/access_controller.go b/router/core/access_controller.go index d09f0dc670..be4b23d15b 100644 --- a/router/core/access_controller.go +++ b/router/core/access_controller.go @@ -55,7 +55,9 @@ func (a *AccessController) Access(w http.ResponseWriter, r *http.Request) (*http return nil, errors.Join(err, ErrUnauthorized) } if auth != nil { - auth.SetScopesClaim(a.scopeClaim) + if a.scopeClaim != "" { + auth.SetScopesClaim(a.scopeClaim) + } w.Header().Set("X-Authenticated-By", auth.Authenticator()) return r.WithContext(authentication.NewContext(r.Context(), auth)), nil } From 6f53a9716691327a45a93148c2cee529e60c7c24 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 7 Apr 2026 11:36:23 +0200 Subject: [PATCH 06/10] fix: do not allow empty scopeClaim --- router/pkg/authentication/authentication.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/pkg/authentication/authentication.go b/router/pkg/authentication/authentication.go index da8f4877be..61fb8f5cac 100644 --- a/router/pkg/authentication/authentication.go +++ b/router/pkg/authentication/authentication.go @@ -61,7 +61,7 @@ func (a *authentication) Claims() Claims { } func (a *authentication) SetScopesClaim(scopeClaim string) { - if a == nil { + if a == nil || scopeClaim == "" { return } a.scopeClaim = scopeClaim From 447d602e22454851dfdfc3babb7830b131f758ef Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Fri, 10 Apr 2026 18:24:52 +0200 Subject: [PATCH 07/10] fix: update scope_claim description to clarify support for top level claims --- docs-website/router/configuration.mdx | 2 +- router-tests/security/authentication_test.go | 15 ++++++++------- router/pkg/config/config.schema.json | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs-website/router/configuration.mdx b/docs-website/router/configuration.mdx index ec701a40a1..d60c5b8096 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -1567,7 +1567,7 @@ In addition to the above JWKS configuration flavours, you can define a list of a | YAML | Required | Description | Default Value | | ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------- | -| scope_claim | | The JWT claim used to read scopes for authorization. Use this when your identity provider stores scopes in a claim other than `scope`. | scope | +| scope_claim | | The JWT claim used to read scopes for authorization. Use this when your identity provider stores scopes in a claim other than `scope`. Only top level claims are supported. | scope | | header_name | | The name of the header. The header is used to extract the token from the request. The default value is 'Authorization'. | Authorization | | header_value_prefix | | The prefix of the header value. The prefix is used to extract the token from the header value. The default value is 'Bearer'. | Bearer | diff --git a/router-tests/security/authentication_test.go b/router-tests/security/authentication_test.go index 8894d1fa9a..17a553fb34 100644 --- a/router-tests/security/authentication_test.go +++ b/router-tests/security/authentication_test.go @@ -606,7 +606,7 @@ func TestAuthentication(t *testing.T) { require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) }) }) - t.Run("scopes required valid token from configured custom scope claim", func(t *testing.T) { + t.Run("scopes are read from custom scope claim", func(t *testing.T) { t.Parallel() authServer, err := jwks.NewServer(t) @@ -647,12 +647,13 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) - require.NoError(t, err) - defer res.Body.Close() - require.Equal(t, http.StatusOK, res.StatusCode) - require.Equal(t, testutils.JwksName, res.Header.Get(xAuthenticatedByHeader)) - data, err := io.ReadAll(res.Body) + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Header: header, + Query: employeesQueryRequiringClaims, + }) + require.Equal(t, http.StatusOK, res.Response.StatusCode) + require.Equal(t, testutils.JwksName, res.Response.Header.Get(xAuthenticatedByHeader)) + data, err := io.ReadAll(res.Response.Body) require.NoError(t, err) require.Equal(t, `{"data":{"employees":[{"id":1,"startDate":"January 2020"},{"id":2,"startDate":"July 2022"},{"id":3,"startDate":"June 2021"},{"id":4,"startDate":"July 2022"},{"id":5,"startDate":"July 2022"},{"id":7,"startDate":"September 2022"},{"id":8,"startDate":"September 2022"},{"id":10,"startDate":"November 2022"},{"id":11,"startDate":"November 2022"},{"id":12,"startDate":"December 2022"}]}}`, string(data)) }) diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index a94de9c8d8..7bac42e13b 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2110,7 +2110,7 @@ }, "scope_claim": { "type": "string", - "description": "The JWT claim to use when reading scopes for authorization. The default value is 'scope'.", + "description": "The JWT claim to use when reading scopes for authorization. Only top level claims are supported. The default value is 'scope'.", "default": "scope", "minLength": 1 }, From ec2490fd949904bd561081c603ed33a868d4c8e6 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 21 Apr 2026 13:22:50 +0200 Subject: [PATCH 08/10] refactor: simplify scope claim handling in authentication process --- router-tests/security/authentication_test.go | 35 ++++++++++---------- router/core/access_controller.go | 5 +-- router/core/context.go | 2 +- router/core/ratelimiter_test.go | 3 -- router/pkg/authentication/authentication.go | 23 ++++++------- router/pkg/authentication/http.go | 4 +-- router/pkg/config/config_test.go | 2 +- 7 files changed, 33 insertions(+), 41 deletions(-) diff --git a/router-tests/security/authentication_test.go b/router-tests/security/authentication_test.go index 17a553fb34..f89d0fe97f 100644 --- a/router-tests/security/authentication_test.go +++ b/router-tests/security/authentication_test.go @@ -30,13 +30,14 @@ import ( ) const ( - employeesQuery = `{"query":"{ employees { id } }"}` - employeesQueryRequiringClaims = `{"query":"{ employees { id startDate } }"}` - employeesExpectedData = `{"data":{"employees":[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":7},{"id":8},{"id":10},{"id":11},{"id":12}]}}` - unauthorizedExpectedData = `{"errors":[{"message":"unauthorized"}]}` - xAuthenticatedByHeader = "X-Authenticated-By" - simpleIntrospectionQuery = `{"query":"{ __type(name: \"Query\") { name } }"}` - simpleIntrospectionExpectedData = `{"data":{"__type":{"name":"Query"}}}` + employeesQuery = `{"query":"{ employees { id } }"}` + employeesQueryRequiringClaims = `{ employees { id startDate } }` + employeesQueryBodyRequiringClaims = `{"query":"` + employeesQueryRequiringClaims + `'"}` + employeesExpectedData = `{"data":{"employees":[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":7},{"id":8},{"id":10},{"id":11},{"id":12}]}}` + unauthorizedExpectedData = `{"errors":[{"message":"unauthorized"}]}` + xAuthenticatedByHeader = "X-Authenticated-By" + simpleIntrospectionQuery = `{"query":"{ __type(name: \"Query\") { name } }"}` + simpleIntrospectionExpectedData = `{"data":{"__type":{"name":"Query"}}}` ) func TestAuthentication(t *testing.T) { @@ -529,7 +530,7 @@ func TestAuthentication(t *testing.T) { core.WithAccessController(accessController), }, }, func(t *testing.T, xEnv *testenv.Environment) { - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -561,7 +562,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -596,7 +597,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -717,7 +718,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -755,7 +756,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -792,7 +793,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -827,7 +828,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer token"}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusUnauthorized, res.StatusCode) @@ -857,7 +858,7 @@ func TestAuthentication(t *testing.T) { }, }, func(t *testing.T, xEnv *testenv.Environment) { // Operations with a token should succeed - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -892,7 +893,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) @@ -927,7 +928,7 @@ func TestAuthentication(t *testing.T) { header := http.Header{ "Authorization": []string{"Bearer " + token}, } - res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryBodyRequiringClaims)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) diff --git a/router/core/access_controller.go b/router/core/access_controller.go index be4b23d15b..cf18511450 100644 --- a/router/core/access_controller.go +++ b/router/core/access_controller.go @@ -50,14 +50,11 @@ func NewAccessController(opts AccessControllerOptions) (*AccessController, error // should not proceed. If it succeeds, a new http.Request with an updated context.Context // is returned. func (a *AccessController) Access(w http.ResponseWriter, r *http.Request) (*http.Request, error) { - auth, err := authentication.AuthenticateHTTPRequest(r.Context(), a.authenticators, r) + auth, err := authentication.AuthenticateHTTPRequest(r.Context(), a.authenticators, r, a.scopeClaim) if err != nil { return nil, errors.Join(err, ErrUnauthorized) } if auth != nil { - if a.scopeClaim != "" { - auth.SetScopesClaim(a.scopeClaim) - } w.Header().Set("X-Authenticated-By", auth.Authenticator()) return r.WithContext(authentication.NewContext(r.Context(), auth)), nil } diff --git a/router/core/context.go b/router/core/context.go index b29e8f9c3e..c0e5baa7a0 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -538,7 +538,7 @@ func (c *requestContext) Authentication() authentication.Authentication { func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth := authentication.FromContext(c.request.Context()) if auth == nil { - auth = authentication.NewEmptyAuthentication() + auth = authentication.NewEmptyAuthentication("") c.request = c.request.WithContext(authentication.NewContext(c.request.Context(), auth)) } auth.SetScopes(scopes) diff --git a/router/core/ratelimiter_test.go b/router/core/ratelimiter_test.go index b3ade9594d..bae2b20689 100644 --- a/router/core/ratelimiter_test.go +++ b/router/core/ratelimiter_test.go @@ -182,9 +182,6 @@ func (f *FakeAuthenticator) Claims() authentication.Claims { return f.claims } -func (f *FakeAuthenticator) SetScopesClaim(scopeClaim string) { -} - func (f *FakeAuthenticator) SetScopes(scopes []string) { //TODO implement me panic("implement me") diff --git a/router/pkg/authentication/authentication.go b/router/pkg/authentication/authentication.go index 61fb8f5cac..c89ac0aebe 100644 --- a/router/pkg/authentication/authentication.go +++ b/router/pkg/authentication/authentication.go @@ -33,8 +33,6 @@ type Authentication interface { // Claims returns the claims of the authenticated request, as returned by // the Authenticator. Claims() Claims - // SetScopesClaim sets the claim key used by Scopes and SetScopes. - SetScopesClaim(scopeClaim string) // SetScopes sets the scopes of the authenticated request. It will replace the scopes already parsed from the claims. // If users desire to append the scopes, they can first run `Scopes` to get the current scopes, and then append the new scopes SetScopes(scopes []string) @@ -60,13 +58,6 @@ func (a *authentication) Claims() Claims { return a.claims } -func (a *authentication) SetScopesClaim(scopeClaim string) { - if a == nil || scopeClaim == "" { - return - } - a.scopeClaim = scopeClaim -} - func (a *authentication) SetScopes(scopes []string) { if a == nil { return @@ -96,7 +87,10 @@ var errUnacceptableAud = errors.New("audience match not found") // has no authentication information, the Authentication result is nil with no error. If the authentication // information is present but some or all of the authenticators fail to validate it, then a non-nil error // will be produced. -func Authenticate(ctx context.Context, authenticators []Authenticator, p Provider) (Authentication, error) { +func Authenticate(ctx context.Context, authenticators []Authenticator, p Provider, scopeClaim string) (Authentication, error) { + if scopeClaim == "" { + scopeClaim = DefaultScopeClaim + } var joinedErrors error for _, auth := range authenticators { claims, err := auth.Authenticate(ctx, p) @@ -117,7 +111,7 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide return &authentication{ authenticator: auth.Name(), claims: claims, - scopeClaim: DefaultScopeClaim, + scopeClaim: scopeClaim, }, nil } // If no authentication failed error will be nil here, @@ -125,8 +119,11 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide return nil, joinedErrors } -func NewEmptyAuthentication() Authentication { +func NewEmptyAuthentication(scopeClaim string) Authentication { + if scopeClaim == "" { + scopeClaim = DefaultScopeClaim + } return &authentication{ - scopeClaim: DefaultScopeClaim, + scopeClaim: scopeClaim, } } diff --git a/router/pkg/authentication/http.go b/router/pkg/authentication/http.go index b2797923f7..dde212fe8b 100644 --- a/router/pkg/authentication/http.go +++ b/router/pkg/authentication/http.go @@ -13,7 +13,7 @@ func (a httpRequestProvider) AuthenticationHeaders() http.Header { // AuthenticateHTTPRequest is a convenience function that calls Authenticate // when the authentication information is provided by an *http.Request -func AuthenticateHTTPRequest(ctx context.Context, authenticators []Authenticator, r *http.Request) (Authentication, error) { +func AuthenticateHTTPRequest(ctx context.Context, authenticators []Authenticator, r *http.Request, scopeClaim string) (Authentication, error) { provider := (*httpRequestProvider)(r) - return Authenticate(ctx, authenticators, provider) + return Authenticate(ctx, authenticators, provider, scopeClaim) } diff --git a/router/pkg/config/config_test.go b/router/pkg/config/config_test.go index d3250856f2..f668788d71 100644 --- a/router/pkg/config/config_test.go +++ b/router/pkg/config/config_test.go @@ -1533,7 +1533,7 @@ authentication: require.NoError(t, err) }) - t.Run("verify scope claim can be configured", func(t *testing.T) { + t.Run("scope claim can be configured", func(t *testing.T) { t.Parallel() f := createTempFileFromFixture(t, ` From fedae4145cd926a5eea6a0fef8c8f6c24b59d747 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 21 Apr 2026 14:27:36 +0200 Subject: [PATCH 09/10] fix: correct JSON formatting in employees query body --- router-tests/security/authentication_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router-tests/security/authentication_test.go b/router-tests/security/authentication_test.go index f89d0fe97f..d8f25e2163 100644 --- a/router-tests/security/authentication_test.go +++ b/router-tests/security/authentication_test.go @@ -32,7 +32,7 @@ import ( const ( employeesQuery = `{"query":"{ employees { id } }"}` employeesQueryRequiringClaims = `{ employees { id startDate } }` - employeesQueryBodyRequiringClaims = `{"query":"` + employeesQueryRequiringClaims + `'"}` + employeesQueryBodyRequiringClaims = `{"query":"` + employeesQueryRequiringClaims + `"}` employeesExpectedData = `{"data":{"employees":[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":7},{"id":8},{"id":10},{"id":11},{"id":12}]}}` unauthorizedExpectedData = `{"errors":[{"message":"unauthorized"}]}` xAuthenticatedByHeader = "X-Authenticated-By" From 17399bdf0de47e839ee885b7c2c4bc5df68c9691 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Tue, 21 Apr 2026 15:12:38 +0200 Subject: [PATCH 10/10] fix: use default scope claim in authentication initialization --- router/core/context.go | 2 +- router/pkg/authentication/authentication.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/router/core/context.go b/router/core/context.go index c0e5baa7a0..8b18a35874 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -538,7 +538,7 @@ func (c *requestContext) Authentication() authentication.Authentication { func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth := authentication.FromContext(c.request.Context()) if auth == nil { - auth = authentication.NewEmptyAuthentication("") + auth = authentication.NewEmptyAuthentication(authentication.DefaultScopeClaim) c.request = c.request.WithContext(authentication.NewContext(c.request.Context(), auth)) } auth.SetScopes(scopes) diff --git a/router/pkg/authentication/authentication.go b/router/pkg/authentication/authentication.go index c89ac0aebe..6deb7307de 100644 --- a/router/pkg/authentication/authentication.go +++ b/router/pkg/authentication/authentication.go @@ -120,9 +120,6 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide } func NewEmptyAuthentication(scopeClaim string) Authentication { - if scopeClaim == "" { - scopeClaim = DefaultScopeClaim - } return &authentication{ scopeClaim: scopeClaim, }