diff --git a/docs/reference/filters.md b/docs/reference/filters.md index bc858894cc..026031bea0 100644 --- a/docs/reference/filters.md +++ b/docs/reference/filters.md @@ -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. diff --git a/filters/auth/auth.go b/filters/auth/auth.go index e1b42ba50f..b138602c79 100644 --- a/filters/auth/auth.go +++ b/filters/auth/auth.go @@ -17,6 +17,7 @@ const ( checkOAuthTokeninfoAllScopes checkOAuthTokeninfoAnyKV checkOAuthTokeninfoAllKV + checkOAuthTokeninfoValidate checkOAuthTokenintrospectionAnyClaims checkOAuthTokenintrospectionAllClaims checkOAuthTokenintrospectionAnyKV diff --git a/filters/auth/tokeninfo.go b/filters/auth/tokeninfo.go index ee68e0e281..a38dd37f5a 100644 --- a/filters/auth/tokeninfo.go +++ b/filters/auth/tokeninfo.go @@ -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 ( @@ -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) @@ -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 @@ -192,6 +212,8 @@ func (s *tokeninfoSpec) Name() string { return filters.OAuthTokeninfoAnyKVName case checkOAuthTokeninfoAllKV: return filters.OAuthTokeninfoAllKVName + case checkOAuthTokeninfoValidate: + return filters.OAuthTokeninfoValidateName } return AuthUnknown } @@ -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 @@ -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) {} diff --git a/filters/auth/tokeninfo_test.go b/filters/auth/tokeninfo_test.go index ceb5dbbb9d..5e3e93b83e 100644 --- a/filters/auth/tokeninfo_test.go +++ b/filters/auth/tokeninfo_test.go @@ -3,6 +3,7 @@ package auth import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -10,9 +11,11 @@ import ( "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" ) @@ -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)) + } + }) + } +} diff --git a/filters/filters.go b/filters/filters.go index 7090a0de47..612de0b723 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -287,6 +287,7 @@ const ( OAuthTokeninfoAllScopeName = "oauthTokeninfoAllScope" OAuthTokeninfoAnyKVName = "oauthTokeninfoAnyKV" OAuthTokeninfoAllKVName = "oauthTokeninfoAllKV" + OAuthTokeninfoValidateName = "oauthTokeninfoValidate" OAuthTokenintrospectionAnyClaimsName = "oauthTokenintrospectionAnyClaims" OAuthTokenintrospectionAllClaimsName = "oauthTokenintrospectionAllClaims" OAuthTokenintrospectionAnyKVName = "oauthTokenintrospectionAnyKV" diff --git a/skipper.go b/skipper.go index 619ad0f8a4..7d230c2d3c 100644 --- a/skipper.go +++ b/skipper.go @@ -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), ) }