Skip to content

Commit

Permalink
filters/auth: add token validator filter (zalando#3126)
Browse files Browse the repository at this point in the history
The `oauthTokeninfoValidate` filter obtains token info and allows request
if there was no error otherwise it responds with
`401 Unauthorized` status and configured response body.

It does nothing if any preceding filter already validated the token or
if route is annotated with configured annotations.

It is useful as a default filter to ensure each request has a valid token.

Since its logic is diffrent from existing oauthTokeninfo* filters
it is implemented as a separate filter type.

Signed-off-by: Alexander Yastrebov <alexander.yastrebov@zalando.de>
  • Loading branch information
AlexanderYastrebov authored Jun 26, 2024
1 parent 87d5b6b commit 4fee4d3
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 0 deletions.
29 changes: 29 additions & 0 deletions docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,35 @@ Examples:
oauthTokeninfoAllKV("k1", "v1", "k2", "v2")
```

#### oauthTokeninfoValidate

> This filter is experimental and may change in the future, please see tests for example usage.
The filter obtains token info and allows request if there was no error
otherwise it responds with `401 Unauthorized` status and configured response body.

It does nothing if any preceding filter already validated the token or if route is annotated with configured annotations.

It is useful as a default filter to ensure each request has a valid token.
[jwtMetrics](#jwtmetrics) filter may be used to discover routes serving requests without a valid token.

The filter requires single string argument that is parsed as YAML.
For convenience use [flow style format](https://yaml.org/spec/1.2.2/#chapter-7-flow-style-productions).

Examples:

```
// without opt-out annotation validates the token
oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")
```

```
// with opt-out annotation does not validate the token
annotate("oauth.disabled", "this endpoint is public") ->
oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")
```


### Tokenintrospection

Tokenintrospection handled by another service.
Expand Down
1 change: 1 addition & 0 deletions filters/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
checkOAuthTokeninfoAllScopes
checkOAuthTokeninfoAnyKV
checkOAuthTokeninfoAllKV
checkOAuthTokeninfoValidate
checkOAuthTokenintrospectionAnyClaims
checkOAuthTokenintrospectionAllClaims
checkOAuthTokenintrospectionAnyKV
Expand Down
77 changes: 77 additions & 0 deletions filters/auth/tokeninfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ package auth

import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"

"github.com/ghodss/yaml"
"github.com/opentracing/opentracing-go"
"github.com/zalando/skipper/filters"
"github.com/zalando/skipper/filters/annotate"
)

const (
Expand Down Expand Up @@ -51,6 +56,14 @@ type (
scopes []string
kv kv
}

tokeninfoValidateFilter struct {
client tokeninfoClient
config struct {
OptOutAnnotations []string `json:"optOutAnnotations,omitempty"`
UnauthorizedResponse string `json:"unauthorizedResponse,omitempty"`
}
}
)

var tokeninfoAuthClient map[string]tokeninfoClient = make(map[string]tokeninfoClient)
Expand Down Expand Up @@ -150,6 +163,13 @@ func NewOAuthTokeninfoAnyKVWithOptions(to TokeninfoOptions) filters.Spec {
}
}

func NewOAuthTokeninfoValidate(to TokeninfoOptions) filters.Spec {
return &tokeninfoSpec{
typ: checkOAuthTokeninfoValidate,
options: to,
}
}

// NewOAuthTokeninfoAnyKV creates a new auth filter specification
// to validate authorization for requests. Current implementation uses
// Bearer tokens to authorize requests and checks that the token
Expand Down Expand Up @@ -192,6 +212,8 @@ func (s *tokeninfoSpec) Name() string {
return filters.OAuthTokeninfoAnyKVName
case checkOAuthTokeninfoAllKV:
return filters.OAuthTokeninfoAllKVName
case checkOAuthTokeninfoValidate:
return filters.OAuthTokeninfoValidateName
}
return AuthUnknown
}
Expand All @@ -218,6 +240,18 @@ func (s *tokeninfoSpec) CreateFilter(args []interface{}) (filters.Filter, error)
return nil, filters.ErrInvalidFilterParameters
}

if s.typ == checkOAuthTokeninfoValidate {
if len(sargs) != 1 {
return nil, fmt.Errorf("requires single string argument")
}

f := &tokeninfoValidateFilter{client: ac}
if err := yaml.Unmarshal([]byte(sargs[0]), &f.config); err != nil {
return nil, fmt.Errorf("failed to parse configuration")
}
return f, nil
}

f := &tokeninfoFilter{typ: s.typ, client: ac, kv: make(map[string][]string)}
switch f.typ {
// all scopes
Expand Down Expand Up @@ -395,3 +429,46 @@ func (f *tokeninfoFilter) Request(ctx filters.FilterContext) {
}

func (f *tokeninfoFilter) Response(filters.FilterContext) {}

func (f *tokeninfoValidateFilter) Request(ctx filters.FilterContext) {
if _, ok := ctx.StateBag()[tokeninfoCacheKey]; ok {
return // tokeninfo was already validated by a preceding filter
}

if len(f.config.OptOutAnnotations) > 0 {
annotations := annotate.GetAnnotations(ctx)
for _, annotation := range f.config.OptOutAnnotations {
if _, ok := annotations[annotation]; ok {
return // opt-out from validation
}
}
}

token, ok := getToken(ctx.Request())
if !ok {
f.serveUnauthorized(ctx)
return
}

tokeninfo, err := f.client.getTokeninfo(token, ctx)
if err != nil {
f.serveUnauthorized(ctx)
return
}

uid, _ := tokeninfo[uidKey].(string)
authorized(ctx, uid)
ctx.StateBag()[tokeninfoCacheKey] = tokeninfo
}

func (f *tokeninfoValidateFilter) serveUnauthorized(ctx filters.FilterContext) {
ctx.Serve(&http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"Content-Length": []string{strconv.Itoa(len(f.config.UnauthorizedResponse))},
},
Body: io.NopCloser(strings.NewReader(f.config.UnauthorizedResponse)),
})
}

func (f *tokeninfoValidateFilter) Response(filters.FilterContext) {}
138 changes: 138 additions & 0 deletions filters/auth/tokeninfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ package auth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/skipper/eskip"
"github.com/zalando/skipper/filters"
"github.com/zalando/skipper/filters/annotate"
"github.com/zalando/skipper/filters/filtertest"
"github.com/zalando/skipper/proxy/proxytest"
)
Expand Down Expand Up @@ -575,3 +578,138 @@ func TestOAuthTokeninfoAllocs(t *testing.T) {
t.Errorf("Expected zero allocations, got %f", allocs)
}
}

func TestOAuthTokeninfoValidate(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("OK"))
}))
defer backend.Close()

const validAuthHeader = "Bearer foobarbaz"

var authRequestsTotal atomic.Int32
testAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authRequestsTotal.Add(1)
if r.Header.Get("Authorization") != validAuthHeader {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.Write([]byte(`{"uid": "foobar", "scope":["foo", "bar"]}`))
}
}))
defer testAuthServer.Close()

tio := TokeninfoOptions{
URL: testAuthServer.URL,
Timeout: testAuthTimeout,
}

for _, tc := range []struct {
name string
precedingFilters string
authHeader string
expectStatus int
expectAuthRequests int32
}{
{
name: "reject missing token",
authHeader: "",
expectStatus: http.StatusUnauthorized,
expectAuthRequests: 0,
},
{
name: "reject invalid token",
authHeader: "Bearer invalid",
expectStatus: http.StatusUnauthorized,
expectAuthRequests: 1,
},
{
name: "reject invalid token type",
authHeader: "Basic foobarbaz",
expectStatus: http.StatusUnauthorized,
expectAuthRequests: 0,
},
{
name: "reject missing token when opt-out is invalid",
precedingFilters: `annotate("oauth.invalid", "invalid opt-out")`,
authHeader: "",
expectStatus: http.StatusUnauthorized,
expectAuthRequests: 0,
},
{
name: "allow valid token",
authHeader: validAuthHeader,
expectStatus: http.StatusOK,
expectAuthRequests: 1,
},
{
name: "allow already validated by a preceding filter",
precedingFilters: `oauthTokeninfoAllScope("foo", "bar")`,
authHeader: validAuthHeader,
expectStatus: http.StatusOK,
expectAuthRequests: 1, // called once by oauthTokeninfoAllScope
},
{
name: "allow missing token when opted-out",
precedingFilters: `annotate("oauth.disabled", "this endpoint is public")`,
authHeader: "",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
{
name: "allow invalid token when opted-out",
precedingFilters: `annotate("oauth.disabled", "this endpoint is public")`,
authHeader: "Bearer invalid",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
{
name: "allow invalid token type when opted-out",
precedingFilters: `annotate("oauth.disabled", "this endpoint is public")`,
authHeader: "Basic foobarbaz",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
fr := make(filters.Registry)
fr.Register(annotate.New())
fr.Register(NewOAuthTokeninfoAllScopeWithOptions(tio))
fr.Register(NewOAuthTokeninfoValidate(tio))

const unauthorizedResponse = `Authentication required, see https://auth.test/foo`

filters := `oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], unauthorizedResponse: '` + unauthorizedResponse + `'}")`
if tc.precedingFilters != "" {
filters = tc.precedingFilters + " -> " + filters
}

p := proxytest.New(fr, eskip.MustParse(fmt.Sprintf(`* -> %s -> "%s"`, filters, backend.URL))...)
defer p.Close()

authRequestsBefore := authRequestsTotal.Load()

req, err := http.NewRequest("GET", p.URL, nil)
require.NoError(t, err)

if tc.authHeader != "" {
req.Header.Set(authHeaderName, tc.authHeader)
}

resp, err := p.Client().Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, tc.expectStatus, resp.StatusCode)
assert.Equal(t, tc.expectAuthRequests, authRequestsTotal.Load()-authRequestsBefore)

if resp.StatusCode == http.StatusUnauthorized {
assert.Equal(t, resp.ContentLength, int64(len(unauthorizedResponse)))

b, err := io.ReadAll(resp.Body)
require.NoError(t, err)

assert.Equal(t, unauthorizedResponse, string(b))
}
})
}
}
1 change: 1 addition & 0 deletions filters/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ const (
OAuthTokeninfoAllScopeName = "oauthTokeninfoAllScope"
OAuthTokeninfoAnyKVName = "oauthTokeninfoAnyKV"
OAuthTokeninfoAllKVName = "oauthTokeninfoAllKV"
OAuthTokeninfoValidateName = "oauthTokeninfoValidate"
OAuthTokenintrospectionAnyClaimsName = "oauthTokenintrospectionAnyClaims"
OAuthTokenintrospectionAllClaimsName = "oauthTokenintrospectionAllClaims"
OAuthTokenintrospectionAnyKVName = "oauthTokenintrospectionAnyKV"
Expand Down
1 change: 1 addition & 0 deletions skipper.go
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,7 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error {
auth.NewOAuthTokeninfoAnyScopeWithOptions(tio),
auth.NewOAuthTokeninfoAllKVWithOptions(tio),
auth.NewOAuthTokeninfoAnyKVWithOptions(tio),
auth.NewOAuthTokeninfoValidate(tio),
)
}

Expand Down

0 comments on commit 4fee4d3

Please sign in to comment.