Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scopes #315

Merged
merged 14 commits into from
Sep 28, 2021
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
71 changes: 61 additions & 10 deletions accesscontrol/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -44,13 +44,15 @@ type JWT struct {
hmacSecret []byte
name string
pubKey *rsa.PublicKey
scopeClaim string
}

type JWTOptions struct {
Algorithm string
Claims hcl.Expression
ClaimsRequired []string
Name string // TODO: more generic (validate)
ScopeClaim string
Source JWTSource
Key []byte
}
Expand Down Expand Up @@ -82,6 +84,7 @@ func NewJWT(options *JWTOptions) (*JWT, error) {
claims: options.Claims,
claimsRequired: options.ClaimsRequired,
name: options.Name,
scopeClaim: options.ScopeClaim,
source: options.Source,
}

Expand Down Expand Up @@ -109,14 +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, diags := seetie.ExpToMap(evalCtx, j.claims)
if diags != nil {
return diags
}

var tokenValue string
var err error

Expand All @@ -143,6 +138,15 @@ func (j *JWT) Validate(req *http.Request) error {
return errors.JwtTokenMissing.Message("token required")
}

claims := make(map[string]interface{})
var diags hcl.Diagnostics
if j.claims != nil {
claims, diags = seetie.ExpToMap(eval.ContextFromRequest(req).HCLContext(), j.claims)
if diags != nil {
return diags
}
}

parser, err := newParser(j.algorithm, claims)
if err != nil {
return err
Expand All @@ -163,13 +167,30 @@ 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{})
}
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
Expand Down Expand Up @@ -220,6 +241,36 @@ func (j *JWT) validateClaims(token *jwt.Token, claims map[string]interface{}) (m
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) {
Expand Down
117 changes: 112 additions & 5 deletions accesscontrol/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -274,6 +274,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 {
Expand Down
110 changes: 110 additions & 0 deletions accesscontrol/scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package accesscontrol

import (
"fmt"
"net/http"

"github.com/avenga/couper/config/request"
"github.com/avenga/couper/errors"
)

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)
}

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
}
Loading