diff --git a/.schema/config.schema.json b/.schema/config.schema.json index 4dcd8a8032..d4bab3edc2 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -201,6 +201,16 @@ "default": "none", "description": "Sets the strategy validation algorithm." }, + "scopesValidator": { + "title": "Scope Validator", + "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 +614,9 @@ "scope_strategy": { "$ref": "#/definitions/scopeStrategy" }, + "scopes_validator": { + "$ref": "#/definitions/scopesValidator" + }, "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 +725,9 @@ "scope_strategy": { "$ref": "#/definitions/scopeStrategy" }, + "scopes_validator": { + "$ref": "#/definitions/scopesValidator" + }, "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..1fbd7efdab --- /dev/null +++ b/credentials/scopes_logical_validator.go @@ -0,0 +1,28 @@ +package credentials + +import ( + "github.com/ory/herodot" + "github.com/pkg/errors" +) + +type ScopesValidator 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..2d51404272 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 + ScopesValidator ScopesValidator + Scope []string + KeyURLs []url.URL } diff --git a/credentials/verifier_default.go b/credentials/verifier_default.go index c80a8ac70b..c3f270b88a 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.ScopesValidator(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..7710af0d8f 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: 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, + ScopesValidator: DefaultValidation, }, token: sign(jwt.MapClaims{ "sub": "sub", diff --git a/driver/configuration/provider.go b/driver/configuration/provider.go index 4293d97092..89cb99a94c 100644 --- a/driver/configuration/provider.go +++ b/driver/configuration/provider.go @@ -5,6 +5,7 @@ package configuration import ( "encoding/json" + "github.com/ory/oathkeeper/credentials" "net/url" "testing" "time" @@ -70,6 +71,7 @@ type Provider interface { PrometheusHideRequestPaths() bool PrometheusCollapseRequestPaths() bool + ToScopesValidation(value string, key string) credentials.ScopesValidator 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..f77136491d 100644 --- a/driver/configuration/provider_koanf.go +++ b/driver/configuration/provider_koanf.go @@ -9,6 +9,8 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "github.com/knadh/koanf/v2" + "github.com/ory/oathkeeper/credentials" "net/url" "strings" "sync" @@ -18,7 +20,6 @@ import ( "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 +269,18 @@ func (v *KoanfProvider) getURL(value string, key string) *url.URL { return u } +func (v *KoanfProvider) ToScopesValidation(value string, key string) credentials.ScopesValidator { + switch strings.ToLower(value) { + case "default": + return credentials.DefaultValidation + case "any": + return credentials.AnyValidation + default: + v.l.Errorf(`Configuration key "%s" declares unknown scope strategy "%s", only "default", "any", are supported. Falling back to strategy "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/pipeline/authn/authenticator_jwt.go b/pipeline/authn/authenticator_jwt.go index 8852efc0d4..80e9acbfa5 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"` + ScopesValidator string `json:"scopes_validator"` 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"), + ScopesValidator: a.c.ToScopesValidation(cf.ScopesValidator, "authenticators.jwt.Config.scopes_validator"), }) if err != nil { de := herodot.ToDefaultError(err, "")