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 d727a3b668..77884f8037 100644 --- a/docs-website/router/configuration.mdx +++ b/docs-website/router/configuration.mdx @@ -1563,6 +1563,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 | @@ -1598,6 +1599,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..d8f25e2163 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) @@ -606,6 +607,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() @@ -665,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) @@ -703,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) @@ -740,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) @@ -775,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) @@ -805,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) @@ -840,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) @@ -875,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 27d1000cb3..cf18511450 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 } @@ -47,7 +50,7 @@ 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) } diff --git a/router/core/context.go b/router/core/context.go index b29e8f9c3e..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/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..6deb7307de 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. @@ -42,6 +44,7 @@ type Authentication interface { type authentication struct { authenticator string claims Claims + scopeClaim string } func (a *authentication) Authenticator() string { @@ -63,14 +66,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 } @@ -84,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) @@ -105,6 +111,7 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide return &authentication{ authenticator: auth.Name(), claims: claims, + scopeClaim: scopeClaim, }, nil } // If no authentication failed error will be nil here, @@ -112,6 +119,8 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide return nil, joinedErrors } -func NewEmptyAuthentication() Authentication { - return &authentication{} +func NewEmptyAuthentication(scopeClaim string) Authentication { + return &authentication{ + 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.go b/router/pkg/config/config.go index 892267b938..514ecdc76c 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -608,6 +608,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 242112f83f..1ca4982df0 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 18f6f2a784..f668788d71 100644 --- a/router/pkg/config/config_test.go +++ b/router/pkg/config/config_test.go @@ -1533,6 +1533,41 @@ authentication: require.NoError(t, err) }) + t.Run("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 2b22f719cf..eed7b3f973 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 97090e690f..d64a0b40fb 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -314,6 +314,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 cf2b7dec45..8816e029a8 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -618,6 +618,7 @@ "Audiences": null } ], + "ScopeClaim": "customscp", "HeaderName": "Authorization", "HeaderValuePrefix": "Bearer", "HeaderSources": [