Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cb2c67b
feat: allow custom scope claim name
alepane21 Apr 1, 2026
74635d0
feat: add documentation about new config
alepane21 Apr 1, 2026
e1aaf99
fix: force scope_claim min length
alepane21 Apr 1, 2026
136505b
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 1, 2026
0e6074b
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 2, 2026
0143c03
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 2, 2026
58e3f00
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 7, 2026
79ed1ff
fix: avoid useless complexity
alepane21 Apr 7, 2026
e93fd0b
Merge remote-tracking branch 'origin/ale/eng-9325-router-allow-differ…
alepane21 Apr 7, 2026
d8065f7
fix: set scope_claims only if different from an empty string
alepane21 Apr 7, 2026
6f53a97
fix: do not allow empty scopeClaim
alepane21 Apr 7, 2026
34e1fec
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 8, 2026
0b6b798
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 8, 2026
9e7f199
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 9, 2026
9efcd97
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 10, 2026
447d602
fix: update scope_claim description to clarify support for top level …
alepane21 Apr 10, 2026
83024a1
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 10, 2026
6c3998c
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 21, 2026
ec2490f
refactor: simplify scope claim handling in authentication process
alepane21 Apr 21, 2026
565db60
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 21, 2026
fedae41
fix: correct JSON formatting in employees query body
alepane21 Apr 21, 2026
463c127
Merge remote-tracking branch 'origin/ale/eng-9325-router-allow-differ…
alepane21 Apr 21, 2026
ec5a8d2
Merge branch 'main' into ale/eng-9325-router-allow-different-name-for…
alepane21 Apr 21, 2026
17399bd
fix: use default scope claim in authentication initialization
alepane21 Apr 21, 2026
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 @@ -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 | <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`. Only top level claims are supported. | 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 @@ -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
Expand Down
87 changes: 70 additions & 17 deletions router-tests/security/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion 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,14 +42,15 @@ func NewAccessController(opts AccessControllerOptions) (*AccessController, error
skipIntrospectionQueries: opts.SkipIntrospectionQueries,
authenticators: opts.Authenticators,
introspectionSkipSecret: opts.IntrospectionSkipSecret,
scopeClaim: opts.ScopeClaim,
}, nil
}

// Access performs authorization and authentication, returning an error if the request
// 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)
}
Expand Down
2 changes: 1 addition & 1 deletion router/core/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
19 changes: 14 additions & 5 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 Down Expand Up @@ -42,6 +44,7 @@ type Authentication interface {
type authentication struct {
authenticator string
claims Claims
scopeClaim string
Comment thread
alepane21 marked this conversation as resolved.
}

func (a *authentication) Authenticator() string {
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -105,13 +111,16 @@ 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,
// even if to claims were found.
return nil, joinedErrors
}

func NewEmptyAuthentication() Authentication {
return &authentication{}
func NewEmptyAuthentication(scopeClaim string) Authentication {
return &authentication{
scopeClaim: scopeClaim,
}
}
4 changes: 2 additions & 2 deletions router/pkg/authentication/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
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. Only top level claims are supported. The default value is 'scope'.",
"default": "scope",
"minLength": 1
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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 @@ -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()

Expand Down
Loading
Loading