diff --git a/.schema/config.schema.json b/.schema/config.schema.json index 4dcd8a8032..23f847e1dd 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -201,6 +201,13 @@ "default": "none", "description": "Sets the strategy validation algorithm." }, + "scopeValidation": { + "title": "Scope Validation", + "type": "string", + "enum": ["default", "any"], + "default": "default", + "description": "Sets the strategy verifier algorithm. Default is logical AND and any serves as OR" + }, "configErrorsRedirect": { "type": "object", "title": "HTTP Redirect Error Handler", @@ -604,6 +611,9 @@ "scope_strategy": { "$ref": "#/definitions/scopeStrategy" }, + "scope_validation": { + "$ref": "#/definitions/ScopeValidation" + }, "token_from": { "title": "Token From", "description": "The location of the token.\n If not configured, the token will be received from a default location - 'Authorization' header.\n One and only one location (header or query) must be specified.", @@ -712,6 +722,9 @@ "scope_strategy": { "$ref": "#/definitions/scopeStrategy" }, + "scope_validation": { + "$ref": "#/definitions/scopeValidation" + }, "pre_authorization": { "title": "Pre-Authorization", "description": "Enable pre-authorization in cases where the OAuth 2.0 Token Introspection endpoint is protected by OAuth 2.0 Bearer Tokens that can be retrieved using the OAuth 2.0 Client Credentials grant.", diff --git a/credentials/scopes_logical_validator.go b/credentials/scopes_logical_validator.go new file mode 100644 index 0000000000..00fa739dc7 --- /dev/null +++ b/credentials/scopes_logical_validator.go @@ -0,0 +1,32 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package credentials + +import ( + "github.com/pkg/errors" + + "github.com/ory/herodot" +) + +type ScopeValidation func(scopeResult map[string]bool) error + +func DefaultValidation(scopeResult map[string]bool) error { + for sc, result := range scopeResult { + if !result { + return errors.WithStack(herodot.ErrInternalServerError.WithReasonf(`JSON Web Token is missing required scope "%s"`, sc)) + } + } + + return nil +} + +func AnyValidation(scopeResult map[string]bool) error { + for _, result := range scopeResult { + if result { + return nil + } + } + + return errors.WithStack(herodot.ErrInternalServerError.WithReasonf(`JSON Web Token is missing required scope`)) +} diff --git a/credentials/verifier.go b/credentials/verifier.go index f644821f6d..0b19901c9c 100644 --- a/credentials/verifier.go +++ b/credentials/verifier.go @@ -25,10 +25,11 @@ type VerifierRegistry interface { } type ValidationContext struct { - Algorithms []string - Issuers []string - Audiences []string - ScopeStrategy fosite.ScopeStrategy - Scope []string - KeyURLs []url.URL + Algorithms []string + Issuers []string + Audiences []string + ScopeStrategy fosite.ScopeStrategy + ScopeValidation ScopeValidation + Scope []string + KeyURLs []url.URL } diff --git a/credentials/verifier_default.go b/credentials/verifier_default.go index c80a8ac70b..b7573ef597 100644 --- a/credentials/verifier_default.go +++ b/credentials/verifier_default.go @@ -120,11 +120,16 @@ func (v *VerifierDefault) Verify( claims["scp"] = s if r.ScopeStrategy != nil { + scopeResult := make(map[string]bool, len(r.Scope)) + for _, sc := range r.Scope { - if !r.ScopeStrategy(s, sc) { - return nil, herodot.ErrUnauthorized.WithReasonf(`JSON Web Token is missing required scope "%s".`, sc) - } + scopeResult[sc] = r.ScopeStrategy(s, sc) + } + + if err := r.ScopeValidation(scopeResult); err != nil { + return nil, err } + } else { if len(r.Scope) > 0 { return nil, errors.WithStack(helper.ErrRuleFeatureDisabled.WithReason("Scope validation was requested but scope strategy is set to \"none\".")) diff --git a/credentials/verifier_default_test.go b/credentials/verifier_default_test.go index 6701923877..dd67a041db 100644 --- a/credentials/verifier_default_test.go +++ b/credentials/verifier_default_test.go @@ -46,12 +46,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should pass because JWT is valid", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -68,15 +69,69 @@ func TestVerifierDefault(t *testing.T) { "scp": []string{"scope-3", "scope-2", "scope-1"}, }, }, + { + d: "should pass because one of scopes is valid", + c: &ValidationContext{ + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "not-scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: AnyValidation, + }, + token: sign(jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "aud": []string{"aud-1", "aud-2"}, + "iss": "iss-2", + "scope": []string{"scope-3", "scope-2", "scope-1"}, + }, "file://../test/stub/jwks-hs.json"), + expectClaims: jwt.MapClaims{ + "sub": "sub", + "exp": float64(now.Add(time.Hour).Unix()), + "aud": []interface{}{"aud-1", "aud-2"}, + "iss": "iss-2", + "scp": []string{"scope-3", "scope-2", "scope-1"}, + }, + }, + { + d: "should fail because one of scopes is invalid and validation is strict", + c: &ValidationContext{ + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "not-scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, + }, + token: sign(jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "aud": []string{"aud-1", "aud-2"}, + "iss": "iss-2", + "scope": []string{"scope-3", "scope-2", "scope-1"}, + }, "file://../test/stub/jwks-hs.json"), + expectClaims: jwt.MapClaims{ + "sub": "sub", + "exp": float64(now.Add(time.Hour).Unix()), + "aud": []interface{}{"aud-1", "aud-2"}, + "iss": "iss-2", + "scp": []string{"scope-3", "scope-2", "scope-1"}, + }, + expectErr: true, + }, { d: "should pass even when scope is a string", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -96,12 +151,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should pass when scope is keyed as scp", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -121,12 +177,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should pass when scope is keyed as scopes", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -164,12 +221,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when algorithm does not match", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-rsa-single.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-rsa-single.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -183,12 +241,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when audience mismatches", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -202,12 +261,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when issuer mismatches", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -221,12 +281,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when issuer mismatches", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -240,12 +301,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when expired", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -259,12 +321,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when nbf in future", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", @@ -279,12 +342,13 @@ func TestVerifierDefault(t *testing.T) { { d: "should fail when iat in future", c: &ValidationContext{ - Algorithms: []string{"HS256"}, - Audiences: []string{"aud-1", "aud-2"}, - Issuers: []string{"iss-1", "iss-2"}, - Scope: []string{"scope-1", "scope-2"}, - KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, - ScopeStrategy: fosite.ExactScopeStrategy, + Algorithms: []string{"HS256"}, + Audiences: []string{"aud-1", "aud-2"}, + Issuers: []string{"iss-1", "iss-2"}, + Scope: []string{"scope-1", "scope-2"}, + KeyURLs: []url.URL{*x.ParseURLOrPanic("file://../test/stub/jwks-hs.json")}, + ScopeStrategy: fosite.ExactScopeStrategy, + ScopeValidation: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", diff --git a/driver/configuration/provider.go b/driver/configuration/provider.go index 4293d97092..18234deb55 100644 --- a/driver/configuration/provider.go +++ b/driver/configuration/provider.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/ory/oathkeeper/credentials" + "github.com/rs/cors" "github.com/ory/fosite" @@ -70,6 +72,7 @@ type Provider interface { PrometheusHideRequestPaths() bool PrometheusCollapseRequestPaths() bool + ToScopeValidation(value string, key string) credentials.ScopeValidation ToScopeStrategy(value string, key string) fosite.ScopeStrategy ParseURLs(sources []string) ([]url.URL, error) JSONWebKeyURLs() []string diff --git a/driver/configuration/provider_koanf.go b/driver/configuration/provider_koanf.go index 35795f03d6..26307aafbb 100644 --- a/driver/configuration/provider_koanf.go +++ b/driver/configuration/provider_koanf.go @@ -15,10 +15,13 @@ import ( "testing" "time" + "github.com/knadh/koanf/v2" + + "github.com/ory/oathkeeper/credentials" + "github.com/dgraph-io/ristretto" "github.com/google/uuid" - "github.com/knadh/koanf/v2" "github.com/pkg/errors" "github.com/rs/cors" "github.com/spf13/pflag" @@ -268,6 +271,18 @@ func (v *KoanfProvider) getURL(value string, key string) *url.URL { return u } +func (v *KoanfProvider) ToScopeValidation(value string, key string) credentials.ScopeValidation { + switch strings.ToLower(value) { + case "default": + return credentials.DefaultValidation + case "any": + return credentials.AnyValidation + default: + v.l.Errorf(`Configuration key "%s" declares unknown scope validation policy "%s", only "default" and "any" are supported. Falling back to policy "default".`, key, value) + return credentials.DefaultValidation + } +} + func (v *KoanfProvider) ToScopeStrategy(value string, key string) fosite.ScopeStrategy { switch s := stringsx.SwitchExact(strings.ToLower(value)); { case s.AddCase("hierarchic"): diff --git a/driver/configuration/provider_koanf_public_test.go b/driver/configuration/provider_koanf_public_test.go index 50cbf91309..0fb474585a 100644 --- a/driver/configuration/provider_koanf_public_test.go +++ b/driver/configuration/provider_koanf_public_test.go @@ -389,6 +389,23 @@ func TestKoanfProvider(t *testing.T) { }) } +func TestToScopeValidation(t *testing.T) { + p, err := configuration.NewKoanfProvider( + context.Background(), + nil, + logrusx.New("", ""), + configx.WithConfigFiles("./../../internal/config/.oathkeeper.yaml"), + ) + require.NoError(t, err) + + assert.Nil(t, p.ToScopeValidation("default", "foo")(map[string]bool{"foo": true})) + assert.NotNil(t, p.ToScopeValidation("default", "foo")(map[string]bool{"foo": true, "bar": false})) + assert.Nil(t, p.ToScopeValidation("any", "foo")(map[string]bool{"foo": true, "bar": false})) + assert.NotNil(t, p.ToScopeValidation("any", "foo")(map[string]bool{})) + assert.NotNil(t, p.ToScopeValidation("whatever", "foo")(map[string]bool{"foo": true, "bar": false})) + +} + func TestToScopeStrategy(t *testing.T) { p, err := configuration.NewKoanfProvider( context.Background(), diff --git a/pipeline/authn/authenticator_jwt.go b/pipeline/authn/authenticator_jwt.go index 8852efc0d4..a5fea11f27 100644 --- a/pipeline/authn/authenticator_jwt.go +++ b/pipeline/authn/authenticator_jwt.go @@ -34,6 +34,7 @@ type AuthenticatorOAuth2JWTConfiguration struct { AllowedAlgorithms []string `json:"allowed_algorithms"` JWKSURLs []string `json:"jwks_urls"` ScopeStrategy string `json:"scope_strategy"` + ScopeValidation string `json:"scope_validation"` BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"` } @@ -105,12 +106,13 @@ func (a *AuthenticatorJWT) Authenticate(r *http.Request, session *Authentication } pt, err := a.r.CredentialsVerifier().Verify(r.Context(), token, &credentials.ValidationContext{ - Algorithms: cf.AllowedAlgorithms, - KeyURLs: jwksu, - Scope: cf.Scope, - Issuers: cf.Issuers, - Audiences: cf.Audience, - ScopeStrategy: a.c.ToScopeStrategy(cf.ScopeStrategy, "authenticators.jwt.Config.scope_strategy"), + Algorithms: cf.AllowedAlgorithms, + KeyURLs: jwksu, + Scope: cf.Scope, + Issuers: cf.Issuers, + Audiences: cf.Audience, + ScopeStrategy: a.c.ToScopeStrategy(cf.ScopeStrategy, "authenticators.jwt.Config.scope_strategy"), + ScopeValidation: a.c.ToScopeValidation(cf.ScopeValidation, "authenticators.jwt.Config.scope_validation"), }) if err != nil { de := herodot.ToDefaultError(err, "")