Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs-website/router/authentication-and-authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ In the current router version, the configuration and behavior of authentication
secret: <your_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
Expand All @@ -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
Expand Down Expand Up @@ -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.

2 changes: 2 additions & 0 deletions docs-website/router/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | <Icon icon="square" /> | 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 | <Icon icon="square" /> | 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 | <Icon icon="square" /> | The prefix of the header value. The prefix is used to extract the token from the header value. The default value is 'Bearer'. | Bearer |

Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions router-tests/security/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions router/core/access_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type AccessControllerOptions struct {
AuthenticationRequired bool
SkipIntrospectionQueries bool
IntrospectionSkipSecret string
ScopeClaim string
}

// AccessController handles both authentication and authorization for the Router
Expand All @@ -30,6 +31,7 @@ type AccessController struct {
authenticators []authentication.Authenticator
skipIntrospectionQueries bool
introspectionSkipSecret string
scopeClaim string
}

// NewAccessController creates a new AccessController.
Expand All @@ -40,6 +42,7 @@ func NewAccessController(opts AccessControllerOptions) (*AccessController, error
skipIntrospectionQueries: opts.SkipIntrospectionQueries,
authenticators: opts.Authenticators,
introspectionSkipSecret: opts.IntrospectionSkipSecret,
scopeClaim: opts.ScopeClaim,
}, nil
}

Expand All @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions router/core/ratelimiter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions router/core/supervisor_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions router/pkg/authentication/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -42,6 +46,7 @@ type Authentication interface {
type authentication struct {
authenticator string
claims Claims
scopeClaim string
}

func (a *authentication) Authenticator() string {
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -113,5 +126,7 @@ func Authenticate(ctx context.Context, authenticators []Authenticator, p Provide
}

func NewEmptyAuthentication() Authentication {
return &authentication{}
return &authentication{
scopeClaim: DefaultScopeClaim,
}
}
1 change: 1 addition & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 6 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2108,6 +2108,12 @@
]
}
},
"scope_claim": {
"type": "string",
"description": "The JWT claim to use when reading scopes for authorization. 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'.",
Expand Down
35 changes: 35 additions & 0 deletions router/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions router/pkg/config/fixtures/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@
"Authentication": {
"JWT": {
"JWKS": null,
"ScopeClaim": "scope",
"HeaderName": "Authorization",
"HeaderValuePrefix": "Bearer",
"HeaderSources": null
Expand Down
1 change: 1 addition & 0 deletions router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@
"Audiences": null
}
],
"ScopeClaim": "customscp",
"HeaderName": "Authorization",
"HeaderValuePrefix": "Bearer",
"HeaderSources": [
Expand Down
Loading