diff --git a/.schemas/authenticators.jwt.schema.json b/.schemas/authenticators.jwt.schema.json index e37c406abb..c18a6ef8b9 100644 --- a/.schemas/authenticators.jwt.schema.json +++ b/.schemas/authenticators.jwt.schema.json @@ -49,6 +49,38 @@ }, "scope_strategy": { "$ref": "https://raw.githubusercontent.com/ory/oathkeeper/master/.schemas/scope_strategy.schema.json#" + }, + "token_from": { + "title": "Token From", + "description": "The location of the token.\n If not configured, the token will be received from a default location - 'Authorization' header.\n One and only one location (header or query) must be specified.", + "oneOf": [ + { + "type": "object", + "required": [ + "header" + ], + "properties": { + "header": { + "title": "Header", + "type": "string", + "description": "The header (case insensitive) that must contain a token for request authentication. It can't be set along with query_parameter." + } + } + }, + { + "type": "object", + "required": [ + "query_parameter" + ], + "properties": { + "query_parameter": { + "title": "Query Parameter", + "type": "string", + "description": "The query parameter (case sensitive) that must contain a token for request authentication. It can't be set along with header." + } + } + } + ] } }, "additionalProperties": false diff --git a/.schemas/authenticators.oauth2_introspection.schema.json b/.schemas/authenticators.oauth2_introspection.schema.json index 97cf6d2b05..a793c60a10 100644 --- a/.schemas/authenticators.oauth2_introspection.schema.json +++ b/.schemas/authenticators.oauth2_introspection.schema.json @@ -101,6 +101,38 @@ "items": { "type": "string" } + }, + "token_from": { + "title": "Token From", + "description": "The location of the token.\n If not configured, the token will be received from a default location - 'Authorization' header.\n One and only one location (header or query) must be specified.", + "oneOf": [ + { + "type": "object", + "required": [ + "header" + ], + "properties": { + "header": { + "title": "Header", + "type": "string", + "description": "The header (case insensitive) that must contain a token for request authentication.\n It can't be set along with query_parameter." + } + } + }, + { + "type": "object", + "required": [ + "query_parameter" + ], + "properties": { + "query_parameter": { + "title": "Query Parameter", + "type": "string", + "description": "The query parameter (case sensitive) that must contain a token for request authentication.\n It can't be set along with header." + } + } + } + ] } }, "required": [ diff --git a/go.mod b/go.mod index 449cfbb31e..34649fb191 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/bxcodec/faker v2.0.1+incompatible github.com/cenkalti/backoff v2.1.1+incompatible + github.com/codegangsta/negroni v1.0.0 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/fsnotify/fsnotify v1.4.7 github.com/ghodss/yaml v1.0.0 diff --git a/helper/bearer.go b/helper/bearer.go index c1fe5765cb..4aac2c9fc8 100644 --- a/helper/bearer.go +++ b/helper/bearer.go @@ -25,9 +25,25 @@ import ( "strings" ) -func BearerTokenFromRequest(r *http.Request) string { - auth := r.Header.Get("Authorization") - split := strings.SplitN(auth, " ", 2) +const ( + defaultAuthorizationHeader = "Authorization" +) + +type BearerTokenLocation struct { + Header *string `json:"header"` + QueryParameter *string `json:"query_parameter"` +} + +func BearerTokenFromRequest(r *http.Request, tokenLocation *BearerTokenLocation) string { + if tokenLocation != nil { + if tokenLocation.Header != nil { + return r.Header.Get(*tokenLocation.Header) + } else if tokenLocation.QueryParameter != nil { + return r.FormValue(*tokenLocation.QueryParameter) + } + } + token := r.Header.Get(defaultAuthorizationHeader) + split := strings.SplitN(token, " ", 2) if len(split) != 2 || !strings.EqualFold(split[0], "bearer") { return "" } diff --git a/helper/bearer_test.go b/helper/bearer_test.go new file mode 100644 index 0000000000..8bce12dd96 --- /dev/null +++ b/helper/bearer_test.go @@ -0,0 +1,68 @@ +package helper_test + +import ( + "net/http" + "testing" + + "github.com/ory/oathkeeper/helper" + "github.com/stretchr/testify/assert" +) + +const ( + defaultHeaderName = "Authorization" +) + +func TestBearerTokenFromRequest(t *testing.T) { + t.Run("case=token should be received from default header if custom location is not set and token is present", func(t *testing.T) { + expectedToken := "token" + request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer " + expectedToken}}} + token := helper.BearerTokenFromRequest(request, nil) + assert.Equal(t, expectedToken, token) + }) + t.Run("case=should return empty string if custom location is not set and token is not present in default header", func(t *testing.T) { + request := &http.Request{} + token := helper.BearerTokenFromRequest(request, nil) + assert.Empty(t, token) + }) + t.Run("case=should return empty string if custom location is set to header and token is not present in that header", func(t *testing.T) { + customHeaderName := "Custom-Auth-Header" + request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer token"}}} + tokenLocation := helper.BearerTokenLocation{Header: &customHeaderName} + token := helper.BearerTokenFromRequest(request, &tokenLocation) + assert.Empty(t, token) + }) + t.Run("case=should return empty string if custom location is set to query parameter and token is not present in that query parameter", func(t *testing.T) { + customQueryParameterName := "Custom-Auth" + request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer token"}}} + tokenLocation := helper.BearerTokenLocation{QueryParameter: &customQueryParameterName} + token := helper.BearerTokenFromRequest(request, &tokenLocation) + assert.Empty(t, token) + }) + t.Run("case=token should be received from custom header if custom location is set to header and token is present", func(t *testing.T) { + expectedToken := "token" + customHeaderName := "Custom-Auth-Header" + request := &http.Request{Header: http.Header{customHeaderName: {expectedToken}}} + tokenLocation := helper.BearerTokenLocation{Header: &customHeaderName} + token := helper.BearerTokenFromRequest(request, &tokenLocation) + assert.Equal(t, expectedToken, token) + }) + t.Run("case=token should be received from custom header if custom location is set to query parameter and token is present", func(t *testing.T) { + expectedToken := "token" + customQueryParameterName := "Custom-Auth" + request := &http.Request{ + Form: map[string][]string{ + customQueryParameterName: []string{expectedToken}, + }, + } + tokenLocation := helper.BearerTokenLocation{QueryParameter: &customQueryParameterName} + token := helper.BearerTokenFromRequest(request, &tokenLocation) + assert.Equal(t, expectedToken, token) + }) + t.Run("case=token should be received from default header if custom token location is set, but neither Header nor Query Param is configured", func(t *testing.T) { + expectedToken := "token" + request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer " + expectedToken}}} + tokenLocation := helper.BearerTokenLocation{} + token := helper.BearerTokenFromRequest(request, &tokenLocation) + assert.Equal(t, expectedToken, token) + }) +} diff --git a/pipeline/authn/authenticator_jwt.go b/pipeline/authn/authenticator_jwt.go index 8bd4383044..cb10bc8dd7 100644 --- a/pipeline/authn/authenticator_jwt.go +++ b/pipeline/authn/authenticator_jwt.go @@ -21,12 +21,13 @@ type AuthenticatorJWTRegistry interface { } type AuthenticatorOAuth2JWTConfiguration struct { - Scope []string `json:"required_scope"` - Audience []string `json:"target_audience"` - Issuers []string `json:"trusted_issuers"` - AllowedAlgorithms []string `json:"allowed_algorithms"` - JWKSURLs []string `json:"jwks_urls"` - ScopeStrategy string `json:"scope_strategy"` + Scope []string `json:"required_scope"` + Audience []string `json:"target_audience"` + Issuers []string `json:"trusted_issuers"` + AllowedAlgorithms []string `json:"allowed_algorithms"` + JWKSURLs []string `json:"jwks_urls"` + ScopeStrategy string `json:"scope_strategy"` + BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"` } type AuthenticatorJWT struct { @@ -67,16 +68,16 @@ func (a *AuthenticatorJWT) Config(config json.RawMessage) (*AuthenticatorOAuth2J } func (a *AuthenticatorJWT) Authenticate(r *http.Request, config json.RawMessage, _ pipeline.Rule) (*AuthenticationSession, error) { - token := helper.BearerTokenFromRequest(r) - if token == "" { - return nil, errors.WithStack(ErrAuthenticatorNotResponsible) - } - cf, err := a.Config(config) if err != nil { return nil, err } + token := helper.BearerTokenFromRequest(r, cf.BearerTokenLocation) + if token == "" { + return nil, errors.WithStack(ErrAuthenticatorNotResponsible) + } + if len(cf.AllowedAlgorithms) == 0 { cf.AllowedAlgorithms = []string{"RS256"} } diff --git a/pipeline/authn/authenticator_jwt_test.go b/pipeline/authn/authenticator_jwt_test.go index 85be6caa5a..8deaaa593a 100644 --- a/pipeline/authn/authenticator_jwt_test.go +++ b/pipeline/authn/authenticator_jwt_test.go @@ -67,13 +67,14 @@ func TestAuthenticatorJWT(t *testing.T) { t.Run("method=authenticate", func(t *testing.T) { for k, tc := range []struct { - setup func() - d string - r *http.Request - config string - expectErr bool - expectCode int - expectSess *AuthenticationSession + setup func() + d string + r *http.Request + config string + expectErr bool + expectExactErr error + expectCode int + expectSess *AuthenticationSession }{ { d: "should fail because no payloads", @@ -85,6 +86,69 @@ func TestAuthenticatorJWT(t *testing.T) { r: &http.Request{Header: http.Header{"Authorization": []string{"bearer invalid.token.sign"}}}, expectErr: true, }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (default)", + r: &http.Request{Header: http.Header{"Foobar": []string{"bearer " + gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + })}}}, + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom header)", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + })}}}, + config: `{"token_from": {"header": "X-Custom-Header"}}`, + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom query parameter)", + r: &http.Request{ + Form: map[string][]string{ + "someOtherQueryParam": []string{ + gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + }), + }, + }, + Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + })}}, + }, + config: `{"token_from": {"query_parameter": "token"}}`, + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should pass because the valid JWT token was provided in a proper location (custom header)", + r: &http.Request{Header: http.Header{"X-Custom-Header": []string{gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + })}}}, + config: `{"token_from": {"header": "X-Custom-Header"}}`, + expectErr: false, + }, + { + d: "should pass because the valid JWT token was provided in a proper location (custom query parameter)", + r: &http.Request{ + Form: map[string][]string{ + "token": []string{ + gen(keys[1], jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + }), + }, + }, + }, + config: `{"token_from": {"query_parameter": "token"}}`, + expectErr: false, + }, { d: "should pass because JWT is valid", r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{ @@ -187,7 +251,7 @@ func TestAuthenticatorJWT(t *testing.T) { expectCode: 401, }, { - d: "should pass because JWT is missing scope", + d: "should fail because JWT is missing scope", r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[2], jwt.MapClaims{ "sub": "sub", "exp": now.Add(time.Hour).Unix(), @@ -197,7 +261,7 @@ func TestAuthenticatorJWT(t *testing.T) { expectErr: true, }, { - d: "should pass because JWT issuer is untrusted", + d: "should fail because JWT issuer is untrusted", r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{ "sub": "sub", "exp": now.Add(time.Hour).Unix(), @@ -207,7 +271,7 @@ func TestAuthenticatorJWT(t *testing.T) { expectErr: true, }, { - d: "should pass because JWT is missing audience", + d: "should fail because JWT is missing audience", r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{ "sub": "sub", "exp": now.Add(time.Hour).Unix(), @@ -235,10 +299,13 @@ func TestAuthenticatorJWT(t *testing.T) { tc.config, _ = sjson.Set(tc.config, "jwks_urls", keys) session, err := a.Authenticate(tc.r, json.RawMessage([]byte(tc.config)), nil) if tc.expectErr { + require.Error(t, err) if tc.expectCode != 0 { assert.Equal(t, tc.expectCode, herodot.ToDefaultError(err, "").StatusCode(), "Status code mismatch") } - require.Error(t, err) + if tc.expectExactErr != nil { + assert.EqualError(t, err, tc.expectExactErr.Error()) + } } else { require.NoError(t, err, "%#v", errors.Cause(err)) } diff --git a/pipeline/authn/authenticator_oauth2_introspection.go b/pipeline/authn/authenticator_oauth2_introspection.go index ae23be6486..c355555ff6 100644 --- a/pipeline/authn/authenticator_oauth2_introspection.go +++ b/pipeline/authn/authenticator_oauth2_introspection.go @@ -20,12 +20,13 @@ import ( ) type AuthenticatorOAuth2IntrospectionConfiguration struct { - Scopes []string `json:"required_scope"` - Audience []string `json:"target_audience"` - Issuers []string `json:"trusted_issuers"` - PreAuth *AuthenticatorOAuth2IntrospectionPreAuthConfiguration `json:"pre_authorization"` - ScopeStrategy string `json:"scope_strategy"` - IntrospectionURL string `json:"introspection_url"` + Scopes []string `json:"required_scope"` + Audience []string `json:"target_audience"` + Issuers []string `json:"trusted_issuers"` + PreAuth *AuthenticatorOAuth2IntrospectionPreAuthConfiguration `json:"pre_authorization"` + ScopeStrategy string `json:"scope_strategy"` + IntrospectionURL string `json:"introspection_url"` + BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"` } type AuthenticatorOAuth2IntrospectionPreAuthConfiguration struct { @@ -70,7 +71,7 @@ func (a *AuthenticatorOAuth2Introspection) Authenticate(r *http.Request, config return nil, err } - token := helper.BearerTokenFromRequest(r) + token := helper.BearerTokenFromRequest(r, cf.BearerTokenLocation) if token == "" { return nil, errors.WithStack(ErrAuthenticatorNotResponsible) } diff --git a/pipeline/authn/authenticator_oauth2_introspection_test.go b/pipeline/authn/authenticator_oauth2_introspection_test.go index 9db2fd3bb5..8f078e478f 100644 --- a/pipeline/authn/authenticator_oauth2_introspection_test.go +++ b/pipeline/authn/authenticator_oauth2_introspection_test.go @@ -27,17 +27,14 @@ import ( "net/http/httptest" "testing" - "github.com/tidwall/sjson" - - "github.com/ory/viper" - + "github.com/julienschmidt/httprouter" "github.com/ory/oathkeeper/driver/configuration" "github.com/ory/oathkeeper/internal" . "github.com/ory/oathkeeper/pipeline/authn" - - "github.com/julienschmidt/httprouter" + "github.com/ory/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" ) func TestAuthenticatorOAuth2Introspection(t *testing.T) { @@ -51,12 +48,13 @@ func TestAuthenticatorOAuth2Introspection(t *testing.T) { t.Run("method=authenticate", func(t *testing.T) { for k, tc := range []struct { - d string - setup func(*testing.T, *httprouter.Router) - r *http.Request - config json.RawMessage - expectErr bool - expectSess *AuthenticationSession + d string + setup func(*testing.T, *httprouter.Router) + r *http.Request + config json.RawMessage + expectErr bool + expectExactErr error + expectSess *AuthenticationSession }{ { d: "should fail because no payloads", @@ -77,6 +75,65 @@ func TestAuthenticatorOAuth2Introspection(t *testing.T) { }, expectErr: true, }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (default)", + r: &http.Request{Header: http.Header{"Foobar": {"bearer token"}}}, + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom header)", + r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}}, + config: []byte(`{"token_from": {"header": "X-Custom-Header"}}`), + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom query parameter)", + r: &http.Request{ + Form: map[string][]string{ + "someOtherQueryParam": []string{"token"}, + }, + Header: http.Header{"Authorization": {"bearer token"}}, + }, + config: []byte(`{"token_from": {"query_parameter": "token"}}`), + expectErr: true, + expectExactErr: ErrAuthenticatorNotResponsible, + }, + { + d: "should pass because the valid JWT token was provided in a proper location (custom header)", + r: &http.Request{Header: http.Header{"X-Custom-Header": {"token"}}}, + config: []byte(`{"token_from": {"header": "X-Custom-Header"}}`), + expectErr: false, + setup: func(t *testing.T, m *httprouter.Router) { + m.POST("/oauth2/introspect", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + require.NoError(t, r.ParseForm()) + require.Equal(t, "token", r.Form.Get("token")) + require.NoError(t, json.NewEncoder(w).Encode(&AuthenticatorOAuth2IntrospectionResult{ + Active: true, + })) + }) + }, + }, + { + d: "should pass because the valid JWT token was provided in a proper location (custom query parameter)", + r: &http.Request{ + Form: map[string][]string{ + "token": []string{"token"}, + }, + }, + config: []byte(`{"token_from": {"query_parameter": "token"}}`), + expectErr: false, + setup: func(t *testing.T, m *httprouter.Router) { + m.POST("/oauth2/introspect", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + require.NoError(t, r.ParseForm()) + require.Equal(t, "token", r.Form.Get("token")) + require.NoError(t, json.NewEncoder(w).Encode(&AuthenticatorOAuth2IntrospectionResult{ + Active: true, + })) + }) + }, + }, { d: "should fail because not active", r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}}, @@ -255,6 +312,9 @@ func TestAuthenticatorOAuth2Introspection(t *testing.T) { sess, err := a.Authenticate(tc.r, tc.config, nil) if tc.expectErr { require.Error(t, err) + if tc.expectExactErr != nil { + assert.EqualError(t, err, tc.expectExactErr.Error()) + } } else { require.NoError(t, err) }