From 70c5cd197a80264a5e99a5df033ceca71b3561a8 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 14 Sep 2021 16:34:01 +0200 Subject: [PATCH 01/13] Added: beta_scope_claim to jwt block; jwt access control yields scopes stored in request.context.scopes --- accesscontrol/jwt.go | 52 ++++++++- accesscontrol/jwt_test.go | 107 ++++++++++++++++++ config/ac_jwt.go | 1 + config/request/context_key.go | 1 + config/runtime/server.go | 1 + docs/REFERENCE.md | 1 + eval/context.go | 6 +- internal/seetie/convert.go | 6 + server/http_integration_test.go | 6 +- .../testdata/integration/config/03_couper.hcl | 2 + 10 files changed, 180 insertions(+), 3 deletions(-) diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go index 0518efadc..6ff624f1f 100644 --- a/accesscontrol/jwt.go +++ b/accesscontrol/jwt.go @@ -42,6 +42,7 @@ type JWT struct { name string parser *jwt.Parser pubKey *rsa.PublicKey + scopeClaim string } type JWTOptions struct { @@ -49,6 +50,7 @@ type JWTOptions struct { Claims map[string]interface{} ClaimsRequired []string Name string // TODO: more generic (validate) + ScopeClaim string Source JWTSource Key []byte } @@ -80,6 +82,7 @@ func NewJWT(options *JWTOptions) (*JWT, error) { claims: options.Claims, claimsRequired: options.ClaimsRequired, name: options.Name, + scopeClaim: options.ScopeClaim, source: options.Source, } @@ -155,13 +158,30 @@ func (j *JWT) Validate(req *http.Request) error { } ctx := req.Context() + acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{}) if !ok { acMap = make(map[string]interface{}) } acMap[j.name] = tokenClaims - ctx = context.WithValue(ctx, request.AccessControls, acMap) + + scopesValues, err := j.getScopeValues(tokenClaims) + if err != nil { + return err + } + + if len(scopesValues) > 0 { + scopes, ok := ctx.Value(request.Scopes).([]string) + if !ok { + scopes = []string{} + } + for _, sc := range scopesValues { + scopes = append(scopes, sc) + } + ctx = context.WithValue(ctx, request.Scopes, scopes) + } + *req = *req.WithContext(ctx) return nil @@ -212,6 +232,36 @@ func (j *JWT) validateClaims(token *jwt.Token) (map[string]interface{}, error) { return tokenClaims, nil } +func (j *JWT) getScopeValues(tokenClaims map[string]interface{}) ([]string, error) { + if j.scopeClaim == "" { + return []string{}, nil + } + scopesFromClaim, exists := tokenClaims[j.scopeClaim] + if !exists { + return nil, fmt.Errorf("Missing expected scope claim %q", j.scopeClaim) + } + + scopeValues := []string{} + // ["foo", "bar"] is stored as []interface{}, not []string, unfortunately + scopesArray, ok := scopesFromClaim.([]interface{}) + if ok { + for _, v := range scopesArray { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("value of scope claim must either be a string containing a space-separated list of scope values or a list of string scope values") + } + scopeValues = append(scopeValues, s) + } + } else { + scopesString, ok := scopesFromClaim.(string) + if !ok { + return nil, fmt.Errorf("value of scope claim must either be a string containing a space-separated list of scope values or a list of string scope values") + } + scopeValues = strings.Split(scopesString, " ") + } + return scopeValues, nil +} + func getBearer(val string) (string, error) { const bearer = "bearer " if strings.HasPrefix(strings.ToLower(val), bearer) { diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go index 793348946..d0661ea38 100644 --- a/accesscontrol/jwt_test.go +++ b/accesscontrol/jwt_test.go @@ -267,6 +267,113 @@ func Test_JWT_Validate(t *testing.T) { } } +func Test_JWT_yields_scopes(t *testing.T) { + signingMethod := jwt.SigningMethodHS256 + algo := acjwt.NewAlgorithm(signingMethod.Alg()) + expScopes := []string{"foo", "bar"} + + tests := []struct { + name string + scopeClaim string + scope interface{} + wantErr bool + }{ + { + "space-separated list", + "scp", + "foo bar", + false, + }, + { + "list of string", + "scoop", + []string{"foo", "bar"}, + false, + }, + { + "error: boolean", + "scope", + true, + true, + }, + { + "error: number", + "scope", + 1.23, + true, + }, + { + "error: list of bool", + "scope", + []bool{true, false}, + true, + }, + { + "error: list of number", + "scope", + []int{1, 2}, + true, + }, + { + "error: mixed list", + "scope", + []interface{}{"eins", 2}, + true, + }, + { + "error: object", + "scope", + map[string]interface{}{"foo": 1, "bar": 1}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + claims[tt.scopeClaim] = tt.scope + tok := jwt.NewWithClaims(signingMethod, claims) + pubKeyBytes := []byte("mySecretK3y") + token, tokenErr := tok.SignedString(pubKeyBytes) + if tokenErr != nil { + t.Error(tokenErr) + } + + source := ac.NewJWTSource("", "Authorization") + j, err := ac.NewJWT(&ac.JWTOptions{ + Algorithm: algo.String(), + Name: "test_ac", + ScopeClaim: tt.scopeClaim, + Source: source, + Key: pubKeyBytes, + }) + if err != nil { + t.Error(err) + return + } + + req := setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token) + + if err = j.Validate(req); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + scopesList, ok := req.Context().Value(request.Scopes).([]string) + if !ok { + t.Errorf("Expected scopes within request context") + } else { + for i, v := range expScopes { + if scopesList[i] != v { + t.Errorf("Scopes do not match, want: %v, got: %v", v, scopesList[i]) + } + } + } + + } + }) + } +} + func newRSAKeyPair() (pubKeyBytes []byte, privKey *rsa.PrivateKey) { privKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/config/ac_jwt.go b/config/ac_jwt.go index 416773e96..1709efb1e 100644 --- a/config/ac_jwt.go +++ b/config/ac_jwt.go @@ -22,6 +22,7 @@ type JWT struct { Name string `hcl:"name,label"` PostParam string `hcl:"post_param,optional"` QueryParam string `hcl:"query_param,optional"` + ScopeClaim string `hcl:"beta_scope_claim,optional"` SignatureAlgorithm string `hcl:"signature_algorithm"` SigningKey string `hcl:"signing_key,optional"` SigningKeyFile string `hcl:"signing_key_file,optional"` diff --git a/config/request/context_key.go b/config/request/context_key.go index a1327df0b..f1c632b62 100644 --- a/config/request/context_key.go +++ b/config/request/context_key.go @@ -15,6 +15,7 @@ const ( ResponseWriter RoundTripName RoundTripProxy + Scopes ServerName TokenRequest TokenRequestRetries diff --git a/config/runtime/server.go b/config/runtime/server.go index c0cf52533..208ff59dc 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -472,6 +472,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log ClaimsRequired: jwtConf.ClaimsRequired, Key: key, Name: jwtConf.Name, + ScopeClaim: jwtConf.ScopeClaim, Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header), }) if err != nil { diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6827c0da5..962d5ccb5 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -349,6 +349,7 @@ required _label_. | `signature_algorithm` |string|-|-|⚠ required. Valid values are: `RS256` `RS384` `RS512` `HS256` `HS384` `HS512`.|-| | `claims` |string|-|Equals/in comparison with JWT payload.|-|-| | `required_claims` | string|-|list of claims that must be given for a valid token |-|-| +| `beta_scope_claim` |string|-|name of claim specifying the scope of token|The claim value must either be a string containing a space-separated list of scope values or a list of string scope values|`beta_scope_claim = "scope"`| The `jwt` block may also be referenced by the [`jwt_sign()` function](#functions), if it has a `signing_ttl` defined. For `HS*` algorithms the signing key is taken from `key`/`key_file`, for `RS*` algorithms, `signing_key` or `signing_key_file` have to be specified. diff --git a/eval/context.go b/eval/context.go index 45eb0ca99..e35326057 100644 --- a/eval/context.go +++ b/eval/context.go @@ -414,6 +414,7 @@ func NewRawOrigin(u *url.URL) *url.URL { func newVariable(ctx context.Context, cookies []*http.Cookie, headers http.Header) ContextMap { acData, _ := ctx.Value(request.AccessControls).(map[string]interface{}) + scopeData, _ := ctx.Value(request.Scopes).([]string) ctxAcMap := make(map[string]cty.Value) for name, data := range acData { dataMap, ok := data.(map[string]interface{}) @@ -422,9 +423,12 @@ func newVariable(ctx context.Context, cookies []*http.Cookie, headers http.Heade } ctxAcMap[name] = seetie.MapToValue(dataMap) } + if len(scopeData) > 0 { + ctxAcMap["scopes"] = seetie.GoToValue(scopeData) + } var ctxAcMapValue cty.Value if len(ctxAcMap) > 0 { - ctxAcMapValue = cty.MapVal(ctxAcMap) + ctxAcMapValue = cty.ObjectVal(ctxAcMap) } else { ctxAcMapValue = cty.MapValEmpty(cty.String) } diff --git a/internal/seetie/convert.go b/internal/seetie/convert.go index 70e103160..43c49ed86 100644 --- a/internal/seetie/convert.go +++ b/internal/seetie/convert.go @@ -93,6 +93,12 @@ func GoToValue(v interface{}) cty.Value { return cty.NumberIntVal(v.(int64)) case float64: return cty.NumberFloatVal(v.(float64)) + case []string: + var list []interface{} + for _, s := range v.([]string) { + list = append(list, s) + } + return ListToValue(list) case []interface{}: return ListToValue(v.([]interface{})) case map[string]interface{}: diff --git a/server/http_integration_test.go b/server/http_integration_test.go index 66ca20000..cab6b8fda 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -2784,7 +2784,7 @@ func TestJWTAccessControl(t *testing.T) { for _, tc := range []testCase{ {"no token", "/jwt", http.Header{}, http.StatusUnauthorized, "access control error: JWTToken: token required"}, {"expired token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjEyMzQ1Njc4OX0.wLWj9XgBZAPoDYPXsmDrEBzR6BUWfwPqQNlR_F0naZA"}}, http.StatusForbidden, "access control error: JWTToken: token is expired by "}, - {"valid token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Qf0lkeZKZ3NJrYm3VdgiQiQ6QTrjCvISshD_q9F8GAM"}}, http.StatusOK, ""}, + {"valid token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwic2NvcGUiOiJmb28gYmFyIiwiaWF0IjoxNTE2MjM5MDIyfQ.7wz7Z7IajfEpwYayfshag6tQVS0e0zZJyjAhuFC0L-E"}}, http.StatusOK, ""}, } { t.Run(tc.path[1:], func(subT *testing.T) { helper := test.New(subT) @@ -2823,6 +2823,10 @@ func TestJWTAccessControl(t *testing.T) { t.Errorf("%q: unexpected sub: %q", tc.name, sub) return } + if scopes := res.Header.Get("X-Scopes"); scopes != `["foo","bar"]` { + t.Errorf("%q: unexpected scope: %q", tc.name, scopes) + return + } }) } } diff --git a/server/testdata/integration/config/03_couper.hcl b/server/testdata/integration/config/03_couper.hcl index 3e84fc8e8..ba64e14b6 100644 --- a/server/testdata/integration/config/03_couper.hcl +++ b/server/testdata/integration/config/03_couper.hcl @@ -90,6 +90,7 @@ server "acs" { response { headers = { x-jwt-sub = request.context.JWTToken.sub + x-scopes = json_encode(request.context.scopes) } } } @@ -112,6 +113,7 @@ definitions { header = "Authorization" signature_algorithm = "HS256" key = "y0urS3cretT08eU5edF0rC0uPerInThe3xamp1e" + beta_scope_claim = "scope" } backend "test" { From 67cdcb99ab7c9bc67c7e94c9031f8cd040e04411 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 14 Sep 2021 18:10:57 +0200 Subject: [PATCH 02/13] check for use of reserved name as access control label --- config/runtime/access_control.go | 5 +- config/runtime/access_control_test.go | 107 ++++++++++++++++++++++++++ docs/REFERENCE.md | 2 +- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 config/runtime/access_control_test.go diff --git a/config/runtime/access_control.go b/config/runtime/access_control.go index bed284a1a..a316e46f9 100644 --- a/config/runtime/access_control.go +++ b/config/runtime/access_control.go @@ -21,8 +21,11 @@ func (m ACDefinitions) Add(name string, ac accesscontrol.AccessControl, eh []*co if n == "" { return errors.Configuration.Message("accessControl requires a label") } + if n == "scopes" { + return errors.Configuration.Message("accessControl uses reserved name as label") + } if _, ok := m[n]; ok { - return errors.Configuration.Message("accessControl already defined: " + n) + return errors.Configuration.Message("accessControl already defined") } m[n] = &AccessControl{ diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go new file mode 100644 index 000000000..501c123f9 --- /dev/null +++ b/config/runtime/access_control_test.go @@ -0,0 +1,107 @@ +package runtime + +import ( + "testing" + + "github.com/avenga/couper/config/configload" + "github.com/avenga/couper/errors" +) + +func TestACDefinitions_errors(t *testing.T) { + tests := []struct { + name string + hcl string + expectedMsg string + }{ + { + "collision: basic_auth/jwt", + ` + server "test" { + } + definitions { + basic_auth "foo" { + } + jwt "foo" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: foo: accessControl already defined", + }, + { + "collision: jwt reserved label", + ` + server "test" { + } + definitions { + jwt "scopes" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: scopes: accessControl uses reserved name as label", + }, + { + "collision: basic_auth reserved label", + ` + server "test" { + } + definitions { + basic_auth "scopes" { + } + } + `, + "configuration error: scopes: accessControl uses reserved name as label", + }, + { + "jwt with empty label", + ` + server "test" { + } + definitions { + jwt "" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: accessControl requires a label", + }, + { + "jwt with empty label", + ` + server "test" { + } + definitions { + basic_auth "" { + } + } + `, + "configuration error: accessControl requires a label", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cf, err := configload.LoadBytes([]byte(tt.hcl), "couper.hcl") + if err != nil { + t.Fatal(err) + } + _, err = NewServerConfiguration(cf, nil, nil) + if err == nil { + t.Errorf("Expected error") + } + logErr, _ := err.(errors.GoError) + if logErr == nil { + t.Error("logErr should not be nil") + } else if logErr.LogError() != tt.expectedMsg { + t.Errorf("\nwant:\t%s\ngot:\t%v", tt.expectedMsg, logErr.LogError()) + } + }) + } +} diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 962d5ccb5..8640cdf27 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -508,7 +508,7 @@ Examples: ## Access Control The configuration of access control is twofold in Couper: You define the particular -type (such as `jwt` or `basic_auth`) in `definitions`, each with a distinct label. +type (such as `jwt` or `basic_auth`) in `definitions`, each with a distinct label (must not be one of the reserved names: `scopes`). Anywhere in the `server` block those labels can be used in the `access_control` list to protect that block. ⚠ access rights are inherited by nested blocks. You can also disable `access_control` for blocks. By typing `disable_access_control = ["bar"]`, From edb2b4dd5c8dc0dc20493498dde6d2a494358e4c Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Wed, 15 Sep 2021 21:17:57 +0200 Subject: [PATCH 03/13] required scope for different levels --- accesscontrol/scope.go | 59 +++++++++++++++++++++++++++++ accesscontrol/scope_test.go | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 accesscontrol/scope.go create mode 100644 accesscontrol/scope_test.go diff --git a/accesscontrol/scope.go b/accesscontrol/scope.go new file mode 100644 index 000000000..05f7f4b26 --- /dev/null +++ b/accesscontrol/scope.go @@ -0,0 +1,59 @@ +package accesscontrol + +import ( + "net/http" +) + +var supportedOperations = []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodOptions, + http.MethodTrace, +} + +type requiredScope struct { + scopes map[string][]string +} + +func newRequiredScope() requiredScope { + return requiredScope{scopes: make(map[string][]string)} +} + +func (r *requiredScope) addScopeMap(scopeMap map[string]string) { + otherScope, otherMethodExists := scopeMap["*"] + for _, op := range supportedOperations { + scope, exists := scopeMap[op] + if exists { + r.addScopeForOperation(op, scope) + } else if otherMethodExists { + r.addScopeForOperation(op, otherScope) + } else { + delete(r.scopes, op) + } + } +} + +func (r *requiredScope) addScopeForOperation(operation, scope string) { + scopes, exists := r.scopes[operation] + if !exists { + if scope == "" { + // method permitted without scope + r.scopes[operation] = []string{} + return + } + // method permitted with required scope + r.scopes[operation] = []string{scope} + return + } + // no additional scope required + if scope == "" { + return + } + // add scope to required scopes for this operation + r.scopes[operation] = append(scopes, scope) +} diff --git a/accesscontrol/scope_test.go b/accesscontrol/scope_test.go new file mode 100644 index 000000000..5b7f35810 --- /dev/null +++ b/accesscontrol/scope_test.go @@ -0,0 +1,74 @@ +package accesscontrol + +import ( + "testing" +) + +func Test_requiredScope(t *testing.T) { + tests := []struct { + name string + base map[string]string + sm map[string]string + want map[string][]string + }{ + { + "only default no scope", + map[string]string{}, + map[string]string{"*": ""}, + map[string][]string{"CONNECT": []string{}, "DELETE": []string{}, "GET": []string{}, "HEAD": []string{}, "OPTIONS": []string{}, "PATCH": []string{}, "POST": []string{}, "PUT": []string{}, "TRACE": []string{}}, + }, + { + "only default read", + map[string]string{}, + map[string]string{"*": "read"}, + map[string][]string{"CONNECT": []string{"read"}, "DELETE": []string{"read"}, "GET": []string{"read"}, "HEAD": []string{"read"}, "OPTIONS": []string{"read"}, "PATCH": []string{"read"}, "POST": []string{"read"}, "PUT": []string{"read"}, "TRACE": []string{"read"}}, + }, + { + "simple scope, simple no scope", + map[string]string{}, + map[string]string{"POST": "write", "PUT": ""}, + map[string][]string{"POST": []string{"write"}, "PUT": []string{}}, + }, + { + "simple scope, simple no scope, with default", + map[string]string{}, + map[string]string{"POST": "write", "PUT": "", "*": "read"}, + map[string][]string{"CONNECT": []string{"read"}, "DELETE": []string{"read"}, "GET": []string{"read"}, "HEAD": []string{"read"}, "OPTIONS": []string{"read"}, "PATCH": []string{"read"}, "POST": []string{"write"}, "PUT": []string{}, "TRACE": []string{"read"}}, + }, + { + "default / simple scope, simple no scope", + map[string]string{"*": "read"}, + map[string]string{"POST": "write", "PUT": ""}, + map[string][]string{"POST": []string{"read", "write"}, "PUT": []string{"read"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(subT *testing.T) { + rs := newRequiredScope() + rs.addScopeMap(tt.base) + rs.addScopeMap(tt.sm) + if len(rs.scopes) != len(tt.want) { + t.Errorf("unexpected scopes: %#v, want: %#v", rs.scopes, tt.want) + return + } + for op, wantScopes := range tt.want { + scopes, exists := rs.scopes[op] + if !exists { + t.Errorf("no scopes for operation %q", op) + return + } + if len(scopes) != len(wantScopes) { + t.Errorf("unexpected scopes for %q: %#v, want: %#v", op, scopes, wantScopes) + return + } + for i, s := range wantScopes { + if scopes[i] != s { + t.Errorf("unexpected scopes for %q: %#v, want: %#v", op, scopes, wantScopes) + return + } + } + } + }) + } +} From f86f6479378cce214c3362e1253f89924cfceb16 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Wed, 15 Sep 2021 21:41:51 +0200 Subject: [PATCH 04/13] scope control validating granted scopes from request against required scopes --- accesscontrol/scope.go | 51 +++++++++++++++ accesscontrol/scope_test.go | 124 ++++++++++++++++++++++++++++++++++++ errors/type_defintions.go | 3 + errors/types_generated.go | 4 ++ 4 files changed, 182 insertions(+) diff --git a/accesscontrol/scope.go b/accesscontrol/scope.go index 05f7f4b26..e2cb70aaa 100644 --- a/accesscontrol/scope.go +++ b/accesscontrol/scope.go @@ -1,7 +1,11 @@ package accesscontrol import ( + "fmt" "net/http" + + "github.com/avenga/couper/config/request" + "github.com/avenga/couper/errors" ) var supportedOperations = []string{ @@ -57,3 +61,50 @@ func (r *requiredScope) addScopeForOperation(operation, scope string) { // add scope to required scopes for this operation r.scopes[operation] = append(scopes, scope) } + +var _ AccessControl = &ScopeControl{} + +type ScopeControl struct { + required requiredScope +} + +func NewScopeControl(scopeMaps []map[string]string) *ScopeControl { + rs := newRequiredScope() + for _, scopeMap := range scopeMaps { + if scopeMap != nil { + rs.addScopeMap(scopeMap) + } + } + return &ScopeControl{required: rs} +} + +// Validate validates the scope values provided by access controls against the required scope values. +func (s *ScopeControl) Validate(req *http.Request) error { + if len(s.required.scopes) == 0 { + return nil + } + requiredScopes, exists := s.required.scopes[req.Method] + if !exists { + return errors.BetaOperationDenied.With(fmt.Errorf("operation %s not permitted", req.Method)) + } + ctx := req.Context() + grantedScope, ok := ctx.Value(request.Scopes).([]string) + if !ok && len(requiredScopes) > 0 { + return errors.BetaInsufficientScope.With(fmt.Errorf("no scope granted")) + } + for _, rs := range requiredScopes { + if !hasGrantedScope(grantedScope, rs) { + return errors.BetaInsufficientScope.With(fmt.Errorf("required scope %q not granted", rs)) + } + } + return nil +} + +func hasGrantedScope(grantedScope []string, scope string) bool { + for _, gs := range grantedScope { + if gs == scope { + return true + } + } + return false +} diff --git a/accesscontrol/scope_test.go b/accesscontrol/scope_test.go index 5b7f35810..41765402c 100644 --- a/accesscontrol/scope_test.go +++ b/accesscontrol/scope_test.go @@ -1,7 +1,13 @@ package accesscontrol import ( + "context" + "net/http" + "net/http/httptest" "testing" + + "github.com/avenga/couper/config/request" + "github.com/avenga/couper/errors" ) func Test_requiredScope(t *testing.T) { @@ -72,3 +78,121 @@ func Test_requiredScope(t *testing.T) { }) } } + +func Test_ScopeControl(t *testing.T) { + tests := []struct { + name string + scopeMaps []map[string]string + method string + grantedScope []string + wantErrorString string + }{ + { + "no method restrictions, no scope required, no scope granted", + []map[string]string{}, + http.MethodGet, + nil, + "", + }, + { + "method permitted, no scope required, no scope granted", + []map[string]string{map[string]string{http.MethodGet: ""}}, + http.MethodGet, + nil, + "", + }, + { + "method permitted, scope required, scope granted", + []map[string]string{map[string]string{http.MethodGet: "read"}}, + http.MethodGet, + []string{"read"}, + "", + }, + { + "method permitted, scope required, scopes granted", + []map[string]string{map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read", "write"}, + "", + }, + { + "method permitted, scopes required, scopes granted", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read", "write"}, + "", + }, + { + "all methods permitted, scope required, scope granted", + []map[string]string{map[string]string{"*": "read"}}, + http.MethodPost, + []string{"read"}, + "", + }, + { + "method not permitted", + []map[string]string{map[string]string{http.MethodGet: ""}}, + http.MethodPost, + nil, + "access control error: operation POST not permitted", + }, + { + "method permitted, scope required, no scope granted", + []map[string]string{map[string]string{http.MethodGet: "read"}}, + http.MethodGet, + nil, + "access control error: no scope granted", + }, + { + "method permitted, scope required, wrong scope granted", + []map[string]string{map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read"}, + `access control error: required scope "write" not granted`, + }, + { + "method permitted, scopes required, missing granted scope", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read"}, + `access control error: required scope "write" not granted`, + }, + { + "method permitted, scopes required, missing granted scopes", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"foo"}, + `access control error: required scope "read" not granted`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(subT *testing.T) { + sc := NewScopeControl(tt.scopeMaps) + req := httptest.NewRequest(tt.method, "/", nil) + if tt.grantedScope != nil { + ctx := req.Context() + ctx = context.WithValue(ctx, request.Scopes, tt.grantedScope) + *req = *req.WithContext(ctx) + } + err := sc.Validate(req) + if tt.wantErrorString == "" && err == nil { + return + } + if tt.wantErrorString == "" && err != nil { + logErr := err.(errors.GoError) + t.Errorf("no error expected, was: %#q", logErr.LogError()) + return + } + if tt.wantErrorString != "" && err == nil { + t.Errorf("no error thrown, expected: %q", tt.wantErrorString) + return + } + logErr := err.(errors.GoError) + if tt.wantErrorString != logErr.LogError() { + t.Errorf("unexpected error thrown, expected: %q, was: %q", tt.wantErrorString, logErr.LogError()) + return + } + }) + } +} diff --git a/errors/type_defintions.go b/errors/type_defintions.go index 898dd3f2a..776b8d5aa 100644 --- a/errors/type_defintions.go +++ b/errors/type_defintions.go @@ -16,4 +16,7 @@ var Definitions = []*Error{ AccessControl.Kind("oauth2"), AccessControl.Kind("saml2"), + + AccessControl.Kind("scope").Kind("beta_operation_denied"), + AccessControl.Kind("scope").Kind("beta_insufficient_scope"), } diff --git a/errors/types_generated.go b/errors/types_generated.go index f74aa6075..0c0dfb836 100644 --- a/errors/types_generated.go +++ b/errors/types_generated.go @@ -11,6 +11,8 @@ var ( JwtTokenMissing = Definitions[5] Oauth2 = Definitions[6] Saml2 = Definitions[7] + BetaOperationDenied = Definitions[8] + BetaInsufficientScope = Definitions[9] ) // typeDefinitions holds all related error definitions which are @@ -28,6 +30,8 @@ var types = typeDefinitions{ "jwt_token_missing": JwtTokenMissing, "oauth2": Oauth2, "saml2": Saml2, + "beta_operation_denied": BetaOperationDenied, + "beta_insufficient_scope": BetaInsufficientScope, } // IsKnown tells the configuration callee if Couper From 51131b13c3fd51400a1a2421260107b532919556 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Thu, 16 Sep 2021 15:16:30 +0200 Subject: [PATCH 05/13] beta_scope attribute for api and endpoint block provides required scope checked by scope access control --- config/api.go | 3 + config/endpoint.go | 2 + config/runtime/server.go | 27 +++++-- docs/ERRORS.md | 2 + docs/REFERENCE.md | 2 + internal/seetie/convert.go | 22 +++++ server/http_integration_test.go | 80 +++++++++++++++++++ .../testdata/integration/config/09_couper.hcl | 32 ++++++++ 8 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 server/testdata/integration/config/09_couper.hcl diff --git a/config/api.go b/config/api.go index 93a501273..18b220ec7 100644 --- a/config/api.go +++ b/config/api.go @@ -3,6 +3,7 @@ package config import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/zclconf/go-cty/cty" ) var _ Inline = &API{} @@ -16,6 +17,8 @@ type API struct { Endpoints Endpoints `hcl:"endpoint,block"` ErrorFile string `hcl:"error_file,optional"` Remain hcl.Body `hcl:",remain"` + Scope cty.Value `hcl:"beta_scope,optional"` + // internally used CatchAllEndpoint *Endpoint } diff --git a/config/endpoint.go b/config/endpoint.go index 2cde495f1..609656bb6 100644 --- a/config/endpoint.go +++ b/config/endpoint.go @@ -3,6 +3,7 @@ package config import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/zclconf/go-cty/cty" "github.com/avenga/couper/config/meta" ) @@ -18,6 +19,7 @@ type Endpoint struct { Remain hcl.Body `hcl:",remain"` RequestBodyLimit string `hcl:"request_body_limit,optional"` Response *Response `hcl:"response,block"` + Scope cty.Value `hcl:"beta_scope,optional"` // internally configured due to multi-label options Proxies Proxies diff --git a/config/runtime/server.go b/config/runtime/server.go index 208ff59dc..f10632f5e 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -166,8 +166,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, - log) + }, nil, log) if err != nil { return nil, err @@ -203,7 +202,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, log) + }, nil, log) if err != nil { return nil, err @@ -267,6 +266,20 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca if parentAPI != nil && parentAPI.CatchAllEndpoint == endpointConf { protectedHandler = epOpts.Error.ServeError(errors.RouteNotFound) } + scopeMaps := []map[string]string{} + if parentAPI != nil { + apiScopeMap, err := seetie.ValueToScopeMap(parentAPI.Scope) + if err != nil { + return nil, err + } + scopeMaps = append(scopeMaps, apiScopeMap) + } + endpointScopeMap, err := seetie.ValueToScopeMap(endpointConf.Scope) + if err != nil { + return nil, err + } + scopeMaps = append(scopeMaps, endpointScopeMap) + scopeControl := ac.NewScopeControl(scopeMaps) endpointHandlers[endpointConf], err = configureProtectedHandler(accessControls, confCtx, accessControl, config.NewAccessControl(endpointConf.AccessControl, endpointConf.DisableAccessControl), &protectedOptions{ @@ -275,7 +288,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, log) + }, scopeControl, log) if err != nil { return nil, err } @@ -562,7 +575,7 @@ type protectedOptions struct { } func configureProtectedHandler(m ACDefinitions, ctx *hcl.EvalContext, parentAC, handlerAC config.AccessControl, - opts *protectedOptions, log *logrus.Entry) (http.Handler, error) { + opts *protectedOptions, scopeControl *ac.ScopeControl, log *logrus.Entry) (http.Handler, error) { var list ac.List for _, acName := range parentAC.Merge(handlerAC).List() { if e := m.MustExist(acName); e != nil { @@ -577,6 +590,10 @@ func configureProtectedHandler(m ACDefinitions, ctx *hcl.EvalContext, parentAC, ac.NewItem(acName, m[acName].Control, eh), ) } + if scopeControl != nil { + // TODO properly create error handler + list = append(list, ac.NewItem("scope", scopeControl, handler.NewErrorHandler(nil, opts.epOpts.Error))) + } if len(list) > 0 { return handler.NewAccessControl(opts.handler, list), nil diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 7cb70a60b..c6ba2c953 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -56,3 +56,5 @@ All errors have a specific type. You can find it in the log field `error_type`. | `jwt_token_invalid` (`jwt`) | The token is not sufficient, e.g. because required claims are missing or have unexpected values. | Send error template with status `403`. | | `saml2` | All `saml2` related errors | Send error template with status `403`. | | `oauth2` | All `beta_oauth2`/`beta_oidc` related errors | Send error template with status `403`. | +| `beta_insufficient_scope` | The request is not in the scope granted to the requester. | Send error template with status `403`. | +| `beta_operation_denied` | The request method is not permitted. | Send error template with status `403`. | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 8640cdf27..c3e5b1707 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -105,6 +105,7 @@ as json error with an error body payload. This can be customized via `error_file |`base_path`|string|-|Configures the path prefix for all requests.|⚠ Must be unique if multiple `api` blocks are defined.| `base_path = "/v1"`| | `error_file` |string|-|Location of the error file template.|-|`error_file = "./my_error_body.json"`| | `access_control` |list|-|Sets predefined [Access Control](#access-control) for `api` block context.|⚠ Inherited by nested blocks.| `access_control = ["foo"]`| +| `beta_scope` |string or object|-|Scope value required to use this API (see [error type](../ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](../ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`| ### Endpoint Block @@ -125,6 +126,7 @@ produce an explicit or implicit client response. |`request_body_limit` |string|`64MiB`|Configures the maximum buffer size while accessing `request.form_body` or `request.json_body` content.|⚠ Valid units are: `KiB, MiB, GiB`|`request_body_limit = "200KiB"`| | `path`|string|-|Changeable part of the upstream URL. Changes the path suffix of the outgoing request.|-|-| |`access_control` |list|-|Sets predefined [Access Control](#access-control) for `endpoint` block context.|-| `access_control = ["foo"]`| +| `beta_scope` |string or object|-|Scope value required to use this endpoint (see [error type](../ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](../ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`| |[Modifiers](#modifiers) |-|-|-|-|-| ### Proxy Block diff --git a/internal/seetie/convert.go b/internal/seetie/convert.go index 43c49ed86..150827a38 100644 --- a/internal/seetie/convert.go +++ b/internal/seetie/convert.go @@ -64,6 +64,28 @@ func ValueToMap(val cty.Value) map[string]interface{} { return result } +func ValueToScopeMap(val cty.Value) (map[string]string, error) { + scopeMap := make(map[string]string) + switch val.Type() { + case cty.NilType: + return nil, nil + case cty.String: + scopeMap["*"] = val.AsString() + return scopeMap, nil + default: + if val.Type().IsObjectType() { + for k, v := range val.AsValueMap() { + if v.Type() != cty.String { + return nil, fmt.Errorf("unsupported value for operation %q in beta_scope", k) + } + scopeMap[strings.ToUpper(k)] = v.AsString() + } + return scopeMap, nil + } + } + return nil, fmt.Errorf("unsupported value for beta_scope") +} + func ValuesMapToValue(m url.Values) cty.Value { result := make(map[string]interface{}) for k, v := range m { diff --git a/server/http_integration_test.go b/server/http_integration_test.go index cab6b8fda..464db4f44 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -23,6 +23,7 @@ import ( "text/template" "time" + "github.com/dgrijalva/jwt-go/v4" "github.com/sirupsen/logrus" logrustest "github.com/sirupsen/logrus/hooks/test" @@ -2860,6 +2861,73 @@ func getAccessControlMessages(hook *logrustest.Hook) string { return "" } +func Test_Scope(t *testing.T) { + h := test.New(t) + client := newClient() + + shutdown, hook := newCouper("testdata/integration/config/09_couper.hcl", test.New(t)) + defer shutdown() + + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "scp": "a", + }) + token, tokenErr := tok.SignedString([]byte("asdf")) + h.Must(tokenErr) + + type testCase struct { + name string + operation string + path string + authorize bool + status int + wantErrLog string + wantErrType string + } + + for _, tc := range []testCase{ + {"unauthorized", http.MethodGet, "/foo", false, http.StatusUnauthorized, "access control error: myjwt: token required", "jwt_token_missing"}, + {"sufficient scope", http.MethodGet, "/foo", true, http.StatusNoContent, "", ""}, + {"additional scope required: insufficient scope", http.MethodPost, "/foo", true, http.StatusForbidden, `access control error: scope: required scope "foo" not granted`, "beta_insufficient_scope"}, + {"operation not permitted", http.MethodDelete, "/foo", true, http.StatusForbidden, "access control error: scope: operation DELETE not permitted", "beta_operation_denied"}, + {"additional scope required by *: insufficient scope", http.MethodGet, "/bar", true, http.StatusForbidden, `access control error: scope: required scope "more" not granted`, "beta_insufficient_scope"}, + {"no additional scope required: sufficient scope", http.MethodDelete, "/bar", true, http.StatusNoContent, "", ""}, + } { + t.Run(fmt.Sprintf("%s_%s_%s", tc.name, tc.operation, tc.path), func(subT *testing.T) { + helper := test.New(subT) + hook.Reset() + + req, err := http.NewRequest(tc.operation, "http://back.end:8080"+tc.path, nil) + if tc.authorize { + req.Header.Set("Authorization", "Bearer "+token) + } + helper.Must(err) + + res, err := client.Do(req) + helper.Must(err) + + if res.StatusCode != tc.status { + t.Errorf("%q: expected Status %d, got: %d", tc.name, tc.status, res.StatusCode) + return + } + + message := getAccessControlMessages(hook) + if tc.wantErrLog == "" { + if message != "" { + t.Errorf("Expected error log: %q, actual: %#v", tc.wantErrLog, message) + } + } else { + if !strings.HasPrefix(message, tc.wantErrLog) { + t.Errorf("Expected error log message: %q, actual: %#v", tc.wantErrLog, message) + } + errorType := getAccessLogErrorType(hook) + if errorType != tc.wantErrType { + t.Errorf("Expected error type: %q, actual: %q", tc.wantErrType, errorType) + } + } + }) + } +} + func getAccessLogUrl(hook *logrustest.Hook) string { for _, entry := range hook.AllEntries() { if entry.Data["type"] == "couper_access" && entry.Data["url"] != "" { @@ -2872,6 +2940,18 @@ func getAccessLogUrl(hook *logrustest.Hook) string { return "" } +func getAccessLogErrorType(hook *logrustest.Hook) string { + for _, entry := range hook.AllEntries() { + if entry.Data["type"] == "couper_access" && entry.Data["error_type"] != "" { + if errorType, ok := entry.Data["error_type"].(string); ok { + return errorType + } + } + } + + return "" +} + func TestWrapperHiJack_WebsocketUpgrade(t *testing.T) { helper := test.New(t) shutdown, _ := newCouper("testdata/integration/api/04_couper.hcl", test.New(t)) diff --git a/server/testdata/integration/config/09_couper.hcl b/server/testdata/integration/config/09_couper.hcl new file mode 100644 index 000000000..db6420432 --- /dev/null +++ b/server/testdata/integration/config/09_couper.hcl @@ -0,0 +1,32 @@ +server "scoped jwt" { + api { + access_control = ["myjwt"] + beta_scope = "a" + endpoint "/foo" { + beta_scope = { + get = "" + post = "foo" + } + response { + status = 204 + } + } + endpoint "/bar" { + beta_scope = { + delete = "" + "*" = "more" + } + response { + status = 204 + } + } + } +} +definitions { + jwt "myjwt" { + header = "authorization" + signature_algorithm = "HS256" + key = "asdf" + beta_scope_claim = "scp" + } +} From f2106b38032951cd2f4551bc58f24b3f816d739e Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Fri, 17 Sep 2021 16:14:17 +0200 Subject: [PATCH 06/13] Changelog entry for scope-related stuff --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cef296a..6129eebca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Unreleased changes are available as `avenga/couper:edge` container. * `Accept: application/json` request header to the OAuth2 token request, in order to make the Github token endpoint respond with a JSON token response ([#307](https://github.com/avenga/couper/pull/307)) * Documentation of [logs](docs/LOGS.md) ([#310](https://github.com/avenga/couper/pull/310)) * `signing_ttl` and `signing_key`/`signing_key_file` to [`jwt` block](./docs/REFERENCE.md#jwt-block) for use with [`jwt_sign()` function](#functions) ([#309](https://github.com/avenga/couper/pull/309)) + * `beta_scope_claim` attribute to [`jwt` block](./docs/REFERENCE.md#jwt-block); `beta_scope` attribute to [`api`](./docs/REFERENCE.md#api-block) and [`endpoint` block](./docs/REFERENCE.md#endpoint-block)s; [error types](./docs/ERRORS.md#error-types) `beta_operation_denied` and `beta_insufficient_scope` ([#315](https://github.com/avenga/couper/pull/315)) * **Changed** * Organized log format fields for uniform access and upstream log ([#300](https://github.com/avenga/couper/pull/300)) From 02ff50335ecc984aeaca31ef39cffa27bfc6735d Mon Sep 17 00:00:00 2001 From: Joe Afflerbach Date: Fri, 24 Sep 2021 10:01:26 +0200 Subject: [PATCH 07/13] Copy paste --- config/runtime/access_control_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go index 501c123f9..24b8463a7 100644 --- a/config/runtime/access_control_test.go +++ b/config/runtime/access_control_test.go @@ -73,7 +73,7 @@ func TestACDefinitions_errors(t *testing.T) { "configuration error: accessControl requires a label", }, { - "jwt with empty label", + "basic_auth with empty label", ` server "test" { } From 372cfa9e0f94a823ec12a4aa71c5617ff3e416e3 Mon Sep 17 00:00:00 2001 From: Joe Afflerbach Date: Fri, 24 Sep 2021 10:54:59 +0200 Subject: [PATCH 08/13] Added missing }, fixed indentation --- docs/README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/README.md b/docs/README.md index aadb49383..717fc315d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -275,7 +275,6 @@ api "my_api" { base_path = "/api/v1" endpoint "/login/**" { - proxy { backend { origin = "http://identityprovider:8080" @@ -284,18 +283,18 @@ api "my_api" { } endpoint "/cart/**" { - - path = "/api/v1/**" - proxy { - url = "http://cartservice:8080" - } + path = "/api/v1/**" + proxy { + url = "http://cartservice:8080" + } + } endpoint "/account/{id}" { proxy { backend { path = "/user/${request.param.id}/info" origin = "http://accountservice:8080" - } + } } } } From 00639de69a367c9a4a6d6951176c2fe76eee7db6 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 28 Sep 2021 09:11:06 +0200 Subject: [PATCH 09/13] missing import after catch-up merge --- config/runtime/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/runtime/server.go b/config/runtime/server.go index c1008d5c0..6bd0a406b 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -30,6 +30,7 @@ import ( "github.com/avenga/couper/handler/middleware" "github.com/avenga/couper/handler/transport" "github.com/avenga/couper/handler/validation" + "github.com/avenga/couper/internal/seetie" "github.com/avenga/couper/oauth2" "github.com/avenga/couper/oauth2/oidc" "github.com/avenga/couper/utils" From 8c6d3286ea00c8e05a31b80583ccb345e6ecd27d Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 28 Sep 2021 11:13:41 +0200 Subject: [PATCH 10/13] convert claims only if present --- accesscontrol/jwt.go | 10 +++++++--- accesscontrol/jwt_test.go | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go index 965335fdf..b3b1d525b 100644 --- a/accesscontrol/jwt.go +++ b/accesscontrol/jwt.go @@ -115,9 +115,13 @@ func (j *JWT) Validate(req *http.Request) error { ctx := req.Context() cctx := ctx.Value(request.ContextType).(content.Context) evalCtx := cctx.HCLContext() - claims, diags := seetie.ExpToMap(evalCtx, j.claims) - if diags != nil { - return diags + claims := make(map[string]interface{}) + var diags hcl.Diagnostics + if j.claims != nil { + claims, diags = seetie.ExpToMap(evalCtx, j.claims) + if diags != nil { + return diags + } } var tokenValue string diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go index 1f0a7aba4..9f83debb7 100644 --- a/accesscontrol/jwt_test.go +++ b/accesscontrol/jwt_test.go @@ -358,7 +358,7 @@ func Test_JWT_yields_scopes(t *testing.T) { return } - req := setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token) + req := setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token)) if err = j.Validate(req); (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) From 25d2948fe10db9e00caa6f46d639e136bc421cdd Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 28 Sep 2021 11:20:53 +0200 Subject: [PATCH 11/13] convert claims later --- accesscontrol/jwt.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go index b3b1d525b..065761397 100644 --- a/accesscontrol/jwt.go +++ b/accesscontrol/jwt.go @@ -112,18 +112,6 @@ func NewJWT(options *JWTOptions) (*JWT, error) { // Validate reading the token from configured source and validates against the key. func (j *JWT) Validate(req *http.Request) error { - ctx := req.Context() - cctx := ctx.Value(request.ContextType).(content.Context) - evalCtx := cctx.HCLContext() - claims := make(map[string]interface{}) - var diags hcl.Diagnostics - if j.claims != nil { - claims, diags = seetie.ExpToMap(evalCtx, j.claims) - if diags != nil { - return diags - } - } - var tokenValue string var err error @@ -150,6 +138,18 @@ func (j *JWT) Validate(req *http.Request) error { return errors.JwtTokenMissing.Message("token required") } + ctx := req.Context() + cctx := ctx.Value(request.ContextType).(content.Context) + evalCtx := cctx.HCLContext() + claims := make(map[string]interface{}) + var diags hcl.Diagnostics + if j.claims != nil { + claims, diags = seetie.ExpToMap(evalCtx, j.claims) + if diags != nil { + return diags + } + } + parser, err := newParser(j.algorithm, claims) if err != nil { return err From 683ee73410276bc8a54e5fc8688fc3aa474b6437 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 28 Sep 2021 11:23:44 +0200 Subject: [PATCH 12/13] use eval.ContextFromRequest(req) --- accesscontrol/jwt.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go index 065761397..8b752811b 100644 --- a/accesscontrol/jwt.go +++ b/accesscontrol/jwt.go @@ -16,7 +16,7 @@ import ( acjwt "github.com/avenga/couper/accesscontrol/jwt" "github.com/avenga/couper/config/request" "github.com/avenga/couper/errors" - "github.com/avenga/couper/eval/content" + "github.com/avenga/couper/eval" "github.com/avenga/couper/internal/seetie" ) @@ -138,13 +138,10 @@ func (j *JWT) Validate(req *http.Request) error { return errors.JwtTokenMissing.Message("token required") } - ctx := req.Context() - cctx := ctx.Value(request.ContextType).(content.Context) - evalCtx := cctx.HCLContext() claims := make(map[string]interface{}) var diags hcl.Diagnostics if j.claims != nil { - claims, diags = seetie.ExpToMap(evalCtx, j.claims) + claims, diags = seetie.ExpToMap(eval.ContextFromRequest(req).HCLContext(), j.claims) if diags != nil { return diags } @@ -170,6 +167,7 @@ func (j *JWT) Validate(req *http.Request) error { return err } + ctx := req.Context() acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{}) if !ok { acMap = make(map[string]interface{}) From 9d94e8d001509fd4e781fb6bf8b242b9d21d5754 Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Tue, 28 Sep 2021 11:40:04 +0200 Subject: [PATCH 13/13] setContext() only if needed --- accesscontrol/jwt_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go index 9f83debb7..a97de1bf4 100644 --- a/accesscontrol/jwt_test.go +++ b/accesscontrol/jwt_test.go @@ -182,27 +182,27 @@ func Test_JWT_Validate(t *testing.T) { algorithm: algo, source: ac.NewJWTSource("", "Authorization"), pubKey: pubKeyBytes, - }, setContext(httptest.NewRequest(http.MethodGet, "/", nil)), true}, + }, httptest.NewRequest(http.MethodGet, "/", nil), true}, {"src: header /w valid bearer", fields{ algorithm: algo, source: ac.NewJWTSource("", "Authorization"), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token)), false}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token), false}, {"src: header /w no cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(httptest.NewRequest(http.MethodGet, "/", nil)), true}, + }, httptest.NewRequest(http.MethodGet, "/", nil), true}, {"src: header /w empty cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", "")), true}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", ""), true}, {"src: header /w valid cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", token)), false}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", token), false}, {"src: header /w valid bearer & claims", fields{ algorithm: algo, claims: map[string]string{ @@ -358,7 +358,7 @@ func Test_JWT_yields_scopes(t *testing.T) { return } - req := setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token)) + req := setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token) if err = j.Validate(req); (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)