From b623ae7f68aac948f8e584fb9254e43a7272adf6 Mon Sep 17 00:00:00 2001 From: wezzle Date: Tue, 15 Dec 2020 13:35:51 +0100 Subject: [PATCH] feat: add bearer_token authenticator (#613) Adds a new authenticator to work with Kratos' new API token. Works the same as the cookie_session authenticator but checks for a bearer token in the Authorization header (unless overwritten by token_from) --- .schema/config.schema.json | 111 ++++++++++ .../authenticators.bearer_token.schema.json | 5 + docs/docs/pipeline/authn.md | 123 +++++++++++ driver/registry_memory.go | 1 + pipeline/authn/authenticator_bearer_token.go | 109 ++++++++++ .../authn/authenticator_bearer_token_test.go | 205 ++++++++++++++++++ 6 files changed, 554 insertions(+) create mode 100644 .schema/pipeline/authenticators.bearer_token.schema.json create mode 100644 pipeline/authn/authenticator_bearer_token.go create mode 100644 pipeline/authn/authenticator_bearer_token_test.go diff --git a/.schema/config.schema.json b/.schema/config.schema.json index c7b76c0cd6..d550053ff3 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -443,6 +443,85 @@ ], "additionalProperties": false }, + "configAuthenticatorsBearerToken": { + "type": "object", + "title": "Bearer Token Authenticator Configuration", + "description": "This section is optional when the authenticator is disabled.", + "properties": { + "check_session_url": { + "title": "Token Check URL", + "type": "string", + "format": "uri", + "description": "The origin to proxy requests to. If the response is a 200 with body `{ \"subject\": \"...\", \"extra\": {} }`. The request will pass the subject through successfully, otherwise it will be marked as unauthorized.\n\n>If this authenticator is enabled, this value is required.", + "examples": [ + "https://session-store-host" + ] + }, + "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": "null" + }, + { + "type": "object", + "additionalProperties": false, + "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 or cookie." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "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 or cookie." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "cookie": { + "title": "Cookie", + "type": "string", + "description": "The cookie (case sensitive) that must contain a token for request authentication.\n It can't be set along with header or query_parameter." + } + } + } + ] + }, + "preserve_path": { + "title": "Preserve Path", + "type": "boolean", + "description": "When set to true, any path specified in `check_session_url` will be preserved instead of overwriting the path with the path from the original request" + }, + "extra_from": { + "title": "Extra JSON Path", + "description": "The `extra` field in the ORY Oathkeeper authentication session is set using this JSON Path. Defaults to `extra`, and could be `@this` (for the root element), `foo.bar` (for key foo.bar), or any other valid GJSON path. See [GSJON Syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) for reference.", + "type": "string", + "default": "extra" + }, + "subject_from": { + "title": "Subject JSON Path", + "description": "The `subject` field in the ORY Oathkeeper authentication session is set using this JSON Path. Defaults to `subject`. See [GSJON Syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) for reference.", + "type": "string", + "default": "sub" + } + }, + "required": [ + "check_session_url" + ], + "additionalProperties": false + }, "configAuthenticatorsJwt": { "type": "object", "title": "JWT Authenticator Configuration", @@ -1232,6 +1311,38 @@ } ] }, + "bearer_token": { + "title": "Bearer Token", + "description": "The [`bearer_token` authenticator](https://www.ory.sh/oathkeeper/docs/pipeline/authn#bearer_token).", + "type": "object", + "properties": { + "enabled": { + "$ref": "#/definitions/handlerSwitch" + } + }, + "oneOf": [ + { + "properties": { + "enabled": { + "const": true + }, + "config": { + "$ref": "#/definitions/configAuthenticatorsBearerToken" + } + }, + "required": [ + "config" + ] + }, + { + "properties": { + "enabled": { + "const": false + } + } + } + ] + }, "jwt": { "title": "JSON Web Token (jwt)", "description": "The [`jwt` authenticator](https://www.ory.sh/oathkeeper/docs/pipeline/authn#jwt).", diff --git a/.schema/pipeline/authenticators.bearer_token.schema.json b/.schema/pipeline/authenticators.bearer_token.schema.json new file mode 100644 index 0000000000..54a9cdfcbb --- /dev/null +++ b/.schema/pipeline/authenticators.bearer_token.schema.json @@ -0,0 +1,5 @@ +{ + "$id": "/.schema/authenticators.bearer_token.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "/.schema/config.schema.json#/definitions/configAuthenticatorsBearerToken" +} diff --git a/docs/docs/pipeline/authn.md b/docs/docs/pipeline/authn.md index 3fa2d6e46e..10e66bcc7e 100644 --- a/docs/docs/pipeline/authn.md +++ b/docs/docs/pipeline/authn.md @@ -328,6 +328,129 @@ HTTP/1.0 401 Status Unauthorized The request is not authorized because the provided credentials are invalid. ``` +## `bearer_token` + +The `bearer_token` authenticator will forward the request method, path and +headers to a session store. If the session store returns `200 OK` and body +`{ "subject": "...", "extra": {} }` then the authenticator will set the subject +appropriately. + +### Configuration + +- `check_session_url` (string, required) - The session store to forward request + method/path/headers to for validation. +- `preserve_path` (boolean, optional) - If set, any path in `check_session_url` + will be preserved instead of replacing the path with the path of the request + being checked. +- `extra_from` (string, optional - defaults to `extra`) - A + [GJSON Path](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) pointing + to the `extra` field. This defaults to `extra`, but it could also be `@this` + (for the root element), `session.foo.bar` for + `{ "subject": "...", "session": { "foo": {"bar": "whatever"} } }`, and so on. +- `subject_from` (string, optional - defaults to `sub`) - A + [GJSON Path](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) pointing + to the `sub` field. This defaults to `sub`. Example: `identity.id` for + `{ "identity": { "id": "1234" } }`. +- `token_from` (object, optional) - The location of the bearer token. If not + configured, the token will be received from a default location - + 'Authorization' header. One and only one location (header, query, or cookie) + must be specified. + - `header` (string, required, one of) - The header (case insensitive) that + must contain a Bearer token for request authentication. It can't be set + along with `query_parameter` or `cookie`. + - `query_parameter` (string, required, one of) - The query parameter (case + sensitive) that must contain a Bearer token for request authentication. It + can't be set along with `header` or `cookie`. + - `cookie` (string, required, one of) - The cookie (case sensitive) that must + contain a Bearer token for request authentication. It can't be set along + with `header` or `query_parameter` + +```yaml +# Global configuration file oathkeeper.yml +authenticators: + bearer_token: + # Set enabled to true if the authenticator should be enabled and false to disable the authenticator. Defaults to false. + enabled: true + + config: + check_session_url: https://session-store-host + token_from: + header: Custom-Authorization-Header + # or + # query_parameter: auth-token + # or + # cookie: auth-token +``` + +```yaml +# Some Access Rule: access-rule-1.yaml +id: access-rule-1 +# match: ... +# upstream: ... +authenticators: + - handler: bearer_token + config: + check_session_url: https://session-store-host + token_from: + query_parameter: auth-token + # or + # header: Custom-Authorization-Header + # or + # cookie: auth-token +``` + +```yaml +# Some Access Rule Preserving Path: access-rule-2.yaml +id: access-rule-2 +# match: ... +# upstream: ... +authenticators: + - handler: bearer_token + config: + check_session_url: https://session-store-host/check-session + token_from: + query_parameter: auth-token + # or + # header: Custom-Authorization-Header + # or + # cookie: auth-token + preserve_path: true +``` + +### Access Rule Example + +```shell +$ cat ./rules.json + +[{ + "id": "some-id", + "upstream": { + "url": "http://my-backend-service" + }, + "match": { + "url": "http://my-app/some-route", + "methods": [ + "GET" + ] + }, + "authenticators": [{ + "handler": "bearer_token" + }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] +}] + +$ curl -X GET -H 'Authorization: Bearer valid-token' http://my-app/some-route + +HTTP/1.0 200 OK +The request has been allowed! The subject is: "peter" + +$ curl -X GET -H 'Authorization: Bearer invalid-token' http://my-app/some-route + +HTTP/1.0 401 Status Unauthorized +The request is not authorized because the provided credentials are invalid. +``` + ## `oauth2_client_credentials` This `oauth2_client_credentials` uses the username and password from HTTP Basic diff --git a/driver/registry_memory.go b/driver/registry_memory.go index 0cc0f056db..a4c9c8c012 100644 --- a/driver/registry_memory.go +++ b/driver/registry_memory.go @@ -348,6 +348,7 @@ func (r *RegistryMemory) prepareAuthn() { interim := []authn.Authenticator{ authn.NewAuthenticatorAnonymous(r.c), authn.NewAuthenticatorCookieSession(r.c), + authn.NewAuthenticatorBearerToken(r.c), authn.NewAuthenticatorJWT(r.c, r), authn.NewAuthenticatorNoOp(r.c), authn.NewAuthenticatorOAuth2ClientCredentials(r.c), diff --git a/pipeline/authn/authenticator_bearer_token.go b/pipeline/authn/authenticator_bearer_token.go new file mode 100644 index 0000000000..5385863fa0 --- /dev/null +++ b/pipeline/authn/authenticator_bearer_token.go @@ -0,0 +1,109 @@ +package authn + +import ( + "encoding/json" + "net/http" + + "github.com/pkg/errors" + "github.com/tidwall/gjson" + + "github.com/ory/go-convenience/stringsx" + + "github.com/ory/oathkeeper/driver/configuration" + "github.com/ory/oathkeeper/helper" + "github.com/ory/oathkeeper/pipeline" +) + +func init() { + gjson.AddModifier("this", func(json, arg string) string { + return json + }) +} + +type AuthenticatorBearerTokenFilter struct { +} + +type AuthenticatorBearerTokenConfiguration struct { + CheckSessionURL string `json:"check_session_url"` + BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"` + PreservePath bool `json:"preserve_path"` + ExtraFrom string `json:"extra_from"` + SubjectFrom string `json:"subject_from"` +} + +type AuthenticatorBearerToken struct { + c configuration.Provider +} + +func NewAuthenticatorBearerToken(c configuration.Provider) *AuthenticatorBearerToken { + return &AuthenticatorBearerToken{ + c: c, + } +} + +func (a *AuthenticatorBearerToken) GetID() string { + return "bearer_token" +} + +func (a *AuthenticatorBearerToken) Validate(config json.RawMessage) error { + if !a.c.AuthenticatorIsEnabled(a.GetID()) { + return NewErrAuthenticatorNotEnabled(a) + } + + _, err := a.Config(config) + return err +} + +func (a *AuthenticatorBearerToken) Config(config json.RawMessage) (*AuthenticatorBearerTokenConfiguration, error) { + var c AuthenticatorBearerTokenConfiguration + if err := a.c.AuthenticatorConfig(a.GetID(), config, &c); err != nil { + return nil, NewErrAuthenticatorMisconfigured(a, err) + } + + if len(c.ExtraFrom) == 0 { + c.ExtraFrom = "extra" + } + + if len(c.SubjectFrom) == 0 { + c.SubjectFrom = "sub" + } + + return &c, nil +} + +func (a *AuthenticatorBearerToken) Authenticate(r *http.Request, session *AuthenticationSession, config json.RawMessage, _ pipeline.Rule) error { + cf, err := a.Config(config) + if err != nil { + return err + } + + token := helper.BearerTokenFromRequest(r, cf.BearerTokenLocation) + if token == "" { + return errors.WithStack(ErrAuthenticatorNotResponsible) + } + + body, err := forwardRequestToSessionStore(r, cf.CheckSessionURL, cf.PreservePath) + if err != nil { + return err + } + + var ( + subject string + extra map[string]interface{} + + subjectRaw = []byte(stringsx.Coalesce(gjson.GetBytes(body, cf.SubjectFrom).Raw, "null")) + extraRaw = []byte(stringsx.Coalesce(gjson.GetBytes(body, cf.ExtraFrom).Raw, "null")) + ) + + if err = json.Unmarshal(subjectRaw, &subject); err != nil { + return helper.ErrForbidden.WithReasonf("The configured subject_from GJSON path returned an error on JSON output: %s", err.Error()).WithDebugf("GJSON path: %s\nBody: %s\nResult: %s", cf.SubjectFrom, body, subjectRaw).WithTrace(err) + } + + if err = json.Unmarshal(extraRaw, &extra); err != nil { + return helper.ErrForbidden.WithReasonf("The configured extra_from GJSON path returned an error on JSON output: %s", err.Error()).WithDebugf("GJSON path: %s\nBody: %s\nResult: %s", cf.ExtraFrom, body, extraRaw).WithTrace(err) + } + + session.Subject = subject + session.Extra = extra + return nil +} diff --git a/pipeline/authn/authenticator_bearer_token_test.go b/pipeline/authn/authenticator_bearer_token_test.go new file mode 100644 index 0000000000..e96ea4deaf --- /dev/null +++ b/pipeline/authn/authenticator_bearer_token_test.go @@ -0,0 +1,205 @@ +package authn_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "testing" + + "github.com/tidwall/sjson" + + "net/http/httptest" + + "github.com/julienschmidt/httprouter" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/oathkeeper/internal" + . "github.com/ory/oathkeeper/pipeline/authn" +) + +func TestAuthenticatorBearerToken(t *testing.T) { + conf := internal.NewConfigurationWithDefaults() + reg := internal.NewRegistry(conf) + + pipelineAuthenticator, err := reg.PipelineAuthenticator("bearer_token") + require.NoError(t, err) + + t.Run("method=authenticate", func(t *testing.T) { + for k, tc := range []struct { + d string + r *http.Request + setup func(*testing.T, *httprouter.Router) + router func(http.ResponseWriter, *http.Request) + config json.RawMessage + expectErr bool + expectExactErr error + expectSess *AuthenticationSession + }{ + { + d: "should fail because no payloads", + r: &http.Request{Header: http.Header{}}, + 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 fail because session store returned 400", + r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}, URL: &url.URL{Path: ""}}, + setup: func(t *testing.T, m *httprouter.Router) { + m.GET("/", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.WriteHeader(400) + }) + }, + expectErr: true, + }, + { + d: "should pass because session store returned 200", + r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}, URL: &url.URL{Path: ""}}, + setup: func(t *testing.T, m *httprouter.Router) { + m.GET("/", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.WriteHeader(200) + w.Write([]byte(`{"sub": "123", "extra": {"foo": "bar"}}`)) + }) + }, + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"foo": "bar"}, + }, + }, + { + d: "should pass through method, path, and headers to auth server", + r: &http.Request{Header: http.Header{"Authorization": {"bearer zyx"}}, URL: &url.URL{Path: "/users/123?query=string"}, Method: "PUT"}, + router: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, "PUT") + assert.Equal(t, r.URL.Path, "/users/123?query=string") + assert.Equal(t, r.Header.Get("Authorization"), "bearer zyx") + w.WriteHeader(200) + w.Write([]byte(`{"sub": "123"}`)) + }, + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + }, + }, + { + d: "should pass through method and headers ONLY to auth server when PreservePath is true", + r: &http.Request{Header: http.Header{"Authorization": {"bearer zyx"}}, URL: &url.URL{Path: "/users/123?query=string"}, Method: "PUT"}, + router: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, "PUT") + assert.Equal(t, r.URL.Path, "/") + assert.Equal(t, r.Header.Get("Authorization"), "bearer zyx") + w.WriteHeader(200) + w.Write([]byte(`{"sub": "123"}`)) + }, + config: []byte(`{"preserve_path": true}`), + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + }, + }, + { + d: "does not pass request body through to auth server", + r: &http.Request{ + Header: http.Header{ + "Authorization": {"bearer zyx"}, + "Content-Length": {"4"}, + }, + URL: &url.URL{Path: "/users/123?query=string"}, + Method: "PUT", + Body: ioutil.NopCloser(bytes.NewBufferString("body")), + }, + router: func(w http.ResponseWriter, r *http.Request) { + requestBody, _ := ioutil.ReadAll(r.Body) + assert.Equal(t, r.ContentLength, int64(0)) + assert.Equal(t, requestBody, []byte{}) + w.WriteHeader(200) + w.Write([]byte(`{"sub": "123"}`)) + }, + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + }, + }, + { + d: "should work with nested extra keys", + r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}, URL: &url.URL{Path: ""}}, + setup: func(t *testing.T, m *httprouter.Router) { + m.GET("/", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.WriteHeader(200) + w.Write([]byte(`{"sub": "123", "session": {"foo": "bar"}}`)) + }) + }, + config: []byte(`{"extra_from": "session"}`), + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"foo": "bar"}, + }, + }, + { + d: "should work with the root key for extra and a custom subject key", + r: &http.Request{Header: http.Header{"Authorization": {"bearer token"}}, URL: &url.URL{Path: ""}}, + setup: func(t *testing.T, m *httprouter.Router) { + m.GET("/", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.WriteHeader(200) + w.Write([]byte(`{"identity": {"id": "123"}, "session": {"foo": "bar"}}`)) + }) + }, + config: []byte(`{"subject_from": "identity.id", "extra_from": "@this"}`), + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"session": map[string]interface{}{"foo": "bar"}, "identity": map[string]interface{}{"id": "123"}}, + }, + }, + } { + t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { + + var ts *httptest.Server + if tc.router != nil { + ts = httptest.NewServer(http.HandlerFunc(tc.router)) + } else { + router := httprouter.New() + if tc.setup != nil { + tc.setup(t, router) + } + ts = httptest.NewServer(router) + } + defer ts.Close() + + tc.config, _ = sjson.SetBytes(tc.config, "check_session_url", ts.URL) + sess := new(AuthenticationSession) + err := pipelineAuthenticator.Authenticate(tc.r, sess, tc.config, nil) + if tc.expectErr { + require.Error(t, err) + if tc.expectExactErr != nil { + assert.EqualError(t, err, tc.expectExactErr.Error(), "%+v", err) + } + } else { + require.NoError(t, err) + } + + if tc.expectSess != nil { + assert.Equal(t, tc.expectSess, sess) + } + }) + } + }) +}