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

JWT: Set private directive to the CC header #418

Merged
merged 8 commits into from
Jan 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

Unreleased changes are available as `avenga/couper:edge` container.

* **Added**
* `disable_private_caching` attribute for the [JWT Block](./docs/REFERENCE.md#jwt-block) ([#418](https://github.com/avenga/couper/pull/418))

* **Fixed**
* missing upstream log field value for `request.proto` ([#421](https://github.com/avenga/couper/pull/421))
* handling of `for` loops in HCL ([#426](https://github.com/avenga/couper/pull/426))
Expand Down Expand Up @@ -39,7 +42,14 @@ On top of that the binary installation has been improved for [homebrew](https://
* The access control for the OIDC redirect endpoint ([`oidc` block](./docs/REFERENCE.md#oidc-block)) now verifies ID token signatures ([#404](https://github.com/avenga/couper/pull/404))
* `header = "Authorization"` is now the default token source for [JWT](./docs/REFERENCE.md#jwt-block) and may be omitted ([#413](https://github.com/avenga/couper/issues/413))
* Improved the validation for unique keys in all map-attributes in the config ([#403](https://github.com/avenga/couper/pull/403))
<<<<<<< HEAD
* Missing [scope or roles claims](./docs/REFERENCE.md#jwt-block), or scope or roles claim with unsupported values are now ignored instead of causing an error ([#380](https://github.com/avenga/couper/issues/380))
=======
* The access control for the OIDC redirect endpoint ([`oidc` block](./docs/REFERENCE.md#oidc-block)) now verifies ID token signatures ([#404](https://github.com/avenga/couper/pull/404))
* Unbeta [OIDC block](./docs/REFERENCE.md#oidc-block). The old block name is still usable with Couper 1.7, but will no longer work with Couper 1.8. ([#400](https://github.com/avenga/couper/pull/400))
* Unbeta the `oauth2_authorization_url()` and `oauth2_verifier()` [function](./docs/REFERENCE.md#functions). The prefix is changed from `beta_oauth_...` to `oauth2_...`. The old function names are still usable with Couper 1.7, but will no longer work with Couper 1.8. ([#400](https://github.com/avenga/couper/pull/400))
* Automatically add the `private` directive to the response `Cache-Control` HTTP header field value for all resources protected by [JWT](./docs/REFERENCE.md#jwt-block) ([#418](https://github.com/avenga/couper/pull/418))
>>>>>>> 311ec60f... Changelog

* **Fixed**
* build-date configuration for binary and docker builds ([#396](https://github.com/avenga/couper/pull/396))
Expand Down
12 changes: 12 additions & 0 deletions accesscontrol/ac.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type AccessControl interface {
Validate(req *http.Request) error
}

type DisablePrivateCaching interface {
DisablePrivateCaching() bool
}

type ProtectedHandler interface {
Child() http.Handler
}
Expand All @@ -57,6 +61,14 @@ func (i ListItem) ErrorHandler() http.Handler {
return i.controlErrHandler
}

func (i ListItem) DisablePrivateCaching() bool {
if c, ok := i.control.(DisablePrivateCaching); ok {
return c.DisablePrivateCaching()
}
// not implemented, always disabled
return true
}

func (f ValidateFunc) Validate(req *http.Request) error {
return f(req)
}
68 changes: 39 additions & 29 deletions accesscontrol/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ const (
Value
)

var _ AccessControl = &JWT{}
var (
_ AccessControl = &JWT{}
_ DisablePrivateCaching = &JWT{}
)

type (
JWTSourceType uint8
Expand All @@ -42,30 +45,32 @@ type (
)

type JWT struct {
algorithms []acjwt.Algorithm
claims hcl.Expression
claimsRequired []string
source JWTSource
hmacSecret []byte
name string
pubKey interface{}
rolesClaim string
rolesMap map[string][]string
scopeClaim string
jwks *jwk.JWKS
algorithms []acjwt.Algorithm
claims hcl.Expression
claimsRequired []string
disablePrivateCaching bool
source JWTSource
hmacSecret []byte
name string
pubKey interface{}
rolesClaim string
rolesMap map[string][]string
scopeClaim string
jwks *jwk.JWKS
}

type JWTOptions struct {
Algorithm string
Claims hcl.Expression
ClaimsRequired []string
Name string // TODO: more generic (validate)
RolesClaim string
RolesMap map[string][]string
ScopeClaim string
Source JWTSource
Key []byte
JWKS *jwk.JWKS
Algorithm string
Claims hcl.Expression
ClaimsRequired []string
DisablePrivateCaching bool
Name string // TODO: more generic (validate)
RolesClaim string
RolesMap map[string][]string
ScopeClaim string
Source JWTSource
Key []byte
JWKS *jwk.JWKS
}

func NewJWTSource(cookie, header string, value hcl.Expression) JWTSource {
Expand Down Expand Up @@ -160,17 +165,22 @@ func newJWT(options *JWTOptions) (*JWT, error) {
}

jwtAC := &JWT{
claims: options.Claims,
claimsRequired: options.ClaimsRequired,
name: options.Name,
rolesClaim: options.RolesClaim,
rolesMap: options.RolesMap,
scopeClaim: options.ScopeClaim,
source: options.Source,
claims: options.Claims,
claimsRequired: options.ClaimsRequired,
disablePrivateCaching: options.DisablePrivateCaching,
name: options.Name,
rolesClaim: options.RolesClaim,
rolesMap: options.RolesMap,
scopeClaim: options.ScopeClaim,
source: options.Source,
}
return jwtAC, nil
}

func (j *JWT) DisablePrivateCaching() bool {
return j.disablePrivateCaching
}

// Validate reading the token from configured source and validates against the key.
func (j *JWT) Validate(req *http.Request) error {
var tokenValue string
Expand Down
39 changes: 20 additions & 19 deletions config/ac_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,26 @@ type Claims hcl.Expression
// JWT represents the <JWT> object.
type JWT struct {
ErrorHandlerSetter
BackendName string `hcl:"backend,optional"`
Claims Claims `hcl:"claims,optional"`
ClaimsRequired []string `hcl:"required_claims,optional"`
Cookie string `hcl:"cookie,optional"`
Header string `hcl:"header,optional"`
JWKsURL string `hcl:"jwks_url,optional"`
JWKsTTL string `hcl:"jwks_ttl,optional"`
Key string `hcl:"key,optional"`
KeyFile string `hcl:"key_file,optional"`
Name string `hcl:"name,label"`
Remain hcl.Body `hcl:",remain"`
RolesClaim string `hcl:"beta_roles_claim,optional"`
RolesMap map[string][]string `hcl:"beta_roles_map,optional"`
ScopeClaim string `hcl:"beta_scope_claim,optional"`
SignatureAlgorithm string `hcl:"signature_algorithm,optional"`
SigningKey string `hcl:"signing_key,optional"`
SigningKeyFile string `hcl:"signing_key_file,optional"`
SigningTTL string `hcl:"signing_ttl,optional"`
TokenValue hcl.Expression `hcl:"token_value,optional"`
BackendName string `hcl:"backend,optional"`
Claims Claims `hcl:"claims,optional"`
ClaimsRequired []string `hcl:"required_claims,optional"`
Cookie string `hcl:"cookie,optional"`
DisablePrivateCaching bool `hcl:"disable_private_caching,optional"`
Header string `hcl:"header,optional"`
JWKsURL string `hcl:"jwks_url,optional"`
JWKsTTL string `hcl:"jwks_ttl,optional"`
Key string `hcl:"key,optional"`
KeyFile string `hcl:"key_file,optional"`
Name string `hcl:"name,label"`
Remain hcl.Body `hcl:",remain"`
RolesClaim string `hcl:"beta_roles_claim,optional"`
RolesMap map[string][]string `hcl:"beta_roles_map,optional"`
ScopeClaim string `hcl:"beta_scope_claim,optional"`
SignatureAlgorithm string `hcl:"signature_algorithm,optional"`
SigningKey string `hcl:"signing_key,optional"`
SigningKeyFile string `hcl:"signing_key_file,optional"`
SigningTTL string `hcl:"signing_ttl,optional"`
TokenValue hcl.Expression `hcl:"token_value,optional"`

// Internally used
BodyContent *hcl.BodyContent
Expand Down
3 changes: 2 additions & 1 deletion config/configload/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package configload

import (
"fmt"
"net/http"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"net/http"

"github.com/avenga/couper/config"
hclbody "github.com/avenga/couper/config/body"
Expand Down
36 changes: 19 additions & 17 deletions config/runtime/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,14 +618,15 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
}

jwt, err = ac.NewJWTFromJWKS(&ac.JWTOptions{
Claims: jwtConf.Claims,
ClaimsRequired: jwtConf.ClaimsRequired,
Name: jwtConf.Name,
RolesClaim: jwtConf.RolesClaim,
RolesMap: jwtConf.RolesMap,
ScopeClaim: jwtConf.ScopeClaim,
Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header, jwtConf.TokenValue),
JWKS: jwks,
Claims: jwtConf.Claims,
ClaimsRequired: jwtConf.ClaimsRequired,
DisablePrivateCaching: jwtConf.DisablePrivateCaching,
Name: jwtConf.Name,
RolesClaim: jwtConf.RolesClaim,
RolesMap: jwtConf.RolesMap,
ScopeClaim: jwtConf.ScopeClaim,
Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header, jwtConf.TokenValue),
JWKS: jwks,
})
if err != nil {
return nil, confErr.With(err)
Expand All @@ -637,15 +638,16 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log
}

jwt, err = ac.NewJWT(&ac.JWTOptions{
Algorithm: jwtConf.SignatureAlgorithm,
Claims: jwtConf.Claims,
ClaimsRequired: jwtConf.ClaimsRequired,
Key: key,
Name: jwtConf.Name,
RolesClaim: jwtConf.RolesClaim,
RolesMap: jwtConf.RolesMap,
ScopeClaim: jwtConf.ScopeClaim,
Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header, jwtConf.TokenValue),
Algorithm: jwtConf.SignatureAlgorithm,
johakoch marked this conversation as resolved.
Show resolved Hide resolved
Claims: jwtConf.Claims,
ClaimsRequired: jwtConf.ClaimsRequired,
DisablePrivateCaching: jwtConf.DisablePrivateCaching,
Key: key,
Name: jwtConf.Name,
RolesClaim: jwtConf.RolesClaim,
RolesMap: jwtConf.RolesMap,
ScopeClaim: jwtConf.ScopeClaim,
Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header, jwtConf.TokenValue),
})

if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ Like all [Access Control](#access-control) types, the `jwt` block is defined in
the [Definitions Block](#definitions-block) and can be referenced in all configuration blocks by its
required _label_.

Since responses from endpoints protected by JWT access controls are not publicly cacheable, a `Cache-Control: private` header field is added to the response, unless this feature is disabled with `disable_private_caching = true`.

|Block name|Context|Label|Nested block(s)|
| :-----------| :-----------| :-----------| :-----------|
| `jwt`| [Definitions Block](#definitions-block)| &#9888; required | [JWKS `backend`](#backend-block), [Error Handler Block](ERRORS.md#error_handler-specification) |
Expand All @@ -379,6 +381,7 @@ required _label_.
| `jwks_url` | string | - | URI pointing to a set of [JSON Web Keys (RFC 7517)](https://datatracker.ietf.org/doc/html/rfc7517) | - | `jwks_url = "http://identityprovider:8080/jwks.json"` |
| `jwks_ttl` | [duration](#duration) | `"1h"` | Time period the JWK set stays valid and may be cached. | - | `jwks_ttl = "1800s"` |
| `backend` | string| - | [backend reference](#backend-block) for enhancing JWKS requests| - | `backend = "jwks_backend"` |
| `disable_private_caching` | bool | `false` | If set to `true`, Couper does not add the `private` directive to the `Cache-Control` HTTP header field value. | - | - |
johakoch marked this conversation as resolved.
Show resolved Hide resolved

The attributes `header`, `cookie` and `token_value` are mutually exclusive.
If all three attributes are missing, `header = "Authorization"` will be implied, i.e. the token will be read from the incoming `Authorization` header.
Expand Down
7 changes: 7 additions & 0 deletions handler/access_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/avenga/couper/accesscontrol"
"github.com/avenga/couper/config/request"
"github.com/avenga/couper/server/writer"
)

var (
Expand All @@ -26,7 +27,13 @@ func NewAccessControl(protected http.Handler, list accesscontrol.List) *AccessCo
}

func (a *AccessControl) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
r, ok := rw.(*writer.Response)

for _, control := range a.acl {
if ok && !control.DisablePrivateCaching() {
r.AddPrivateCC()
}

if err := control.Validate(req); err != nil {
*req = *req.WithContext(context.WithValue(req.Context(), request.Error, err))
control.ErrorHandler().ServeHTTP(rw, req)
Expand Down
56 changes: 56 additions & 0 deletions server/http_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -3324,6 +3325,61 @@ func TestJWTAccessControl_round(t *testing.T) {
}
}

func TestJWT_CacheControl_private(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.qSLnmYgnkcOjxlOjFhUHQpCfTQ5elzKY3Mq6gRVT4iI"
client := newClient()

shutdown, hook := newCouper("testdata/integration/config/10_couper.hcl", test.New(t))
defer shutdown()

var noCC []string

type testCase struct {
name string
path string
setToken bool
expStatus int
expCC []string
}

for _, tc := range []testCase{
{"no token; no cc from ep", "/cc-private/no-cc", false, 401, []string{"private"}},
{"no token; cc public from ep", "/cc-private/cc-public", false, 401, []string{"private"}},
{"no token; no cc from ep; disable", "/no-cc-private/no-cc", false, 401, noCC},
{"no token; cc public from ep; disable", "/no-cc-private/cc-public", false, 401, noCC},
{"token; no cc from ep", "/cc-private/no-cc", true, 204, []string{"private"}},
{"token; cc public from ep", "/cc-private/cc-public", true, 204, []string{"private", "public"}},
{"token; no public cc from ep; disable", "/no-cc-private/no-cc", true, 204, noCC},
{"token; cc public from ep; disable", "/no-cc-private/cc-public", true, 204, []string{"public"}},
} {
t.Run(tc.name, func(subT *testing.T) {
helper := test.New(subT)
hook.Reset()

req, err := http.NewRequest(http.MethodGet, "http://back.end:8080"+tc.path, nil)
helper.Must(err)
if tc.setToken {
req.Header.Set("Authorization", "Bearer "+token)
}

res, err := client.Do(req)
helper.Must(err)

if res.StatusCode != tc.expStatus {
subT.Errorf("expected Status %d, got: %d", tc.expStatus, res.StatusCode)
return
}

cc := res.Header.Values("Cache-Control")
sort.Strings(cc)

if !cmp.Equal(tc.expCC, cc) {
subT.Errorf("%s", cmp.Diff(tc.expCC, cc))
}
})
}
}

func getAccessControlMessages(hook *logrustest.Hook) string {
for _, entry := range hook.AllEntries() {
if entry.Message != "" {
Expand Down
Loading