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 ed6a243a30..d60c5b8096 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -1567,6 +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`. 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 | @@ -1602,6 +1603,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 diff --git a/router-tests/security/authentication_test.go b/router-tests/security/authentication_test.go index 7cbfbe3122..17a553fb34 100644 --- a/router-tests/security/authentication_test.go +++ b/router-tests/security/authentication_test.go @@ -606,6 +606,58 @@ 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 are read from 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 := 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)) + }) + }) 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..be4b23d15b 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,9 @@ func (a *AccessController) Access(w http.ResponseWriter, r *http.Request) (*http 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/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..61fb8f5cac 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 || scopeClaim == "" { + return + } + a.scopeClaim = scopeClaim +} + func (a *authentication) SetScopes(scopes []string) { if a == nil { return @@ -63,14 +75,14 @@ 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.scopeClaim] = strings.Join(scopes, " ") } func (a *authentication) Scopes() []string { if a == nil { return nil } - scopes, ok := a.claims["scope"].(string) + scopes, ok := a.claims[a.scopeClaim].(string) if !ok { return nil } @@ -105,6 +117,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 +126,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 c89c049402..e81c431a61 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 d9c0a44e6e..7bac42e13b 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2108,6 +2108,12 @@ ] } }, + "scope_claim": { + "type": "string", + "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 + }, "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 073dd02324..0db33a859b 100644 --- a/router/pkg/config/config_test.go +++ b/router/pkg/config/config_test.go @@ -1411,6 +1411,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 f2275edac5..3f9832df76 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 e4237fd1c5..1434a39aea 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 9ef3ce7892..b5efdafdc6 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": [