diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000000..95788af2da --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,8 @@ +version: "2" +plugins: + gofmt: + enabled: true + golint: + enabled: true + govet: + enabled: true diff --git a/.schemas/authenticators.remote_json.schema.json b/.schemas/authenticators.remote_json.schema.json new file mode 100644 index 0000000000..a3ccdb1476 --- /dev/null +++ b/.schemas/authenticators.remote_json.schema.json @@ -0,0 +1,23 @@ +{ + "$id": "https://raw.githubusercontent.com/ory/oathkeeper/master/.schemas/authenticators.cookie_session.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Forward Authenticator Configuration", + "description": "This section is optional when the authenticator is disabled.", + "properties": { + "service_url": { + "title": "Service 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://service-host"] + }, + "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" + } + }, + "required": ["service_url"], + "additionalProperties": false +} diff --git a/driver/registry_memory.go b/driver/registry_memory.go index ae793b2b7a..e17dd79a51 100644 --- a/driver/registry_memory.go +++ b/driver/registry_memory.go @@ -372,6 +372,7 @@ func (r *RegistryMemory) prepareAuthn() { authn.NewAuthenticatorOAuth2ClientCredentials(r.c, r.Logger()), authn.NewAuthenticatorOAuth2Introspection(r.c, r.Logger()), authn.NewAuthenticatorUnauthorized(r.c), + authn.NewAuthenticatorRemoteJSON(r.c), } r.authenticators = map[string]authn.Authenticator{} diff --git a/pipeline/authn/authenticator_remote_json.go b/pipeline/authn/authenticator_remote_json.go new file mode 100644 index 0000000000..a9092b4baf --- /dev/null +++ b/pipeline/authn/authenticator_remote_json.go @@ -0,0 +1,185 @@ +package authn + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/pkg/errors" + "github.com/tidwall/gjson" + + "github.com/ory/go-convenience/stringsx" + + "github.com/ory/herodot" + + "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 AuthenticatorRemoteJSONFilter struct { +} + +type AuthenticatorRemoteJSONConfiguration struct { + ServiceURL string `json:"service_url"` + PreservePath bool `json:"preserve_path"` + ExtraFrom string `json:"extra_from"` + SubjectFrom string `json:"subject_from"` + Method string `json:"method"` + UseOriginalMethod bool `json:"use_original_method"` +} + +type AuthenticatorRemoteJSON struct { + c configuration.Provider +} + +func NewAuthenticatorRemoteJSON(c configuration.Provider) *AuthenticatorRemoteJSON { + return &AuthenticatorRemoteJSON{ + c: c, + } +} + +func (a *AuthenticatorRemoteJSON) GetID() string { + return "remote_json" +} + +func (a *AuthenticatorRemoteJSON) Validate(config json.RawMessage) error { + if !a.c.AuthenticatorIsEnabled(a.GetID()) { + return NewErrAuthenticatorNotEnabled(a) + } + + _, err := a.Config(config) + return err +} + +func (a *AuthenticatorRemoteJSON) Config(config json.RawMessage) (*AuthenticatorRemoteJSONConfiguration, error) { + var c AuthenticatorRemoteJSONConfiguration + if err := a.c.AuthenticatorConfig(a.GetID(), config, &c); err != nil { + return nil, NewErrAuthenticatorMisconfigured(a, err) + } + + return &c, nil +} + +func (a *AuthenticatorRemoteJSON) Authenticate(r *http.Request, session *AuthenticationSession, config json.RawMessage, _ pipeline.Rule) error { + cfg, err := a.Config(config) + if err != nil { + return err + } + + method := forwardMethod(r, cfg) + + body, err := forwardRequestToAuthenticator(r, method, cfg.ServiceURL, cfg.PreservePath) + if err != nil { + return err + } + + var ( + subject string + extra map[string]interface{} + + subjectRaw = []byte(stringsx.Coalesce(gjson.GetBytes(body, cfg.SubjectFrom).Raw, "null")) + extraRaw = []byte(stringsx.Coalesce(gjson.GetBytes(body, cfg.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", cfg.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", cfg.ExtraFrom, body, extraRaw). + WithTrace(err) + } + + session.Subject = subject + session.Extra = extra + return nil +} + +func forwardMethod(r *http.Request, cfg *AuthenticatorRemoteJSONConfiguration) string { + method := cfg.Method + if len(method) == 0 { + if cfg.UseOriginalMethod { + return r.Method + } else { + return http.MethodPost + } + } + return cfg.Method +} + +func forwardRequestToAuthenticator(r *http.Request, method string, serviceURL string, preservePath bool) (json.RawMessage, error) { + reqUrl, err := url.Parse(serviceURL) + if err != nil { + return nil, errors.WithStack( + herodot. + ErrInternalServerError.WithReasonf("Unable to parse remote URL: %s", err), + ) + } + + if !preservePath { + reqUrl.Path = r.URL.Path + } + + var forwardRequestBody io.ReadCloser = nil + if r.Body != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, helper.ErrBadRequest.WithReason(err.Error()).WithTrace(err) + } + + err = r.Body.Close() + if err != nil { + return nil, errors.WithStack( + herodot. + ErrInternalServerError. + WithReasonf("Could not close body reader: %s\n", err), + ) + } + + // Unfortunately the body reader needs to be read once to forward the request, + // thus the upstream request will fail miserably without recreating a fresh ReaderCloser + forwardRequestBody = io.NopCloser(bytes.NewReader(body)) + r.Body = io.NopCloser(bytes.NewReader(body)) + } + + req := http.Request{ + Method: method, + URL: reqUrl, + Header: r.Header, + Body: forwardRequestBody, + } + res, err := http.DefaultClient.Do(req.WithContext(r.Context())) + if err != nil { + return nil, helper.ErrForbidden.WithReason(err.Error()).WithTrace(err) + } + + return handleResponse(res) +} + +func handleResponse(r *http.Response) (json.RawMessage, error) { + if r.StatusCode == http.StatusOK { + body, err := io.ReadAll(r.Body) + if err != nil { + return json.RawMessage{}, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Remote server returned error: %+v", err)) + } + return body, nil + } else { + return json.RawMessage{}, errors.WithStack(helper.ErrUnauthorized) + } +} diff --git a/pipeline/authn/authenticator_remote_json_test.go b/pipeline/authn/authenticator_remote_json_test.go new file mode 100644 index 0000000000..517a73eb4b --- /dev/null +++ b/pipeline/authn/authenticator_remote_json_test.go @@ -0,0 +1,207 @@ +package authn_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/oathkeeper/internal" + . "github.com/ory/oathkeeper/pipeline/authn" +) + +func TestAuthenticatorRemoteJSON(t *testing.T) { + conf := internal.NewConfigurationWithDefaults() + reg := internal.NewRegistry(conf) + session := new(AuthenticationSession) + + pipelineAuthenticator, err := reg.PipelineAuthenticator("remote_json") + require.NoError(t, err) + + t.Run("method=authenticate", func(t *testing.T) { + t.Run("description=should fail because remote returned 400", func(t *testing.T) { + testServer, _ := makeServiceServer(400, `{}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("GET", "/", map[string]string{"sessionid": "zyx"}, ""), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s"}`, testServer.URL)), + nil, + ) + require.Error(t, err, "%#v", errors.Cause(err)) + }) + + t.Run("description=should pass because remote returned 200", func(t *testing.T) { + testServer, _ := makeServiceServer(200, `{"subject": "123", "extra": {"foo": "bar"}}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("GET", "/", map[string]string{"sessionid": "zyx"}, ""), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s"}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Equal(t, &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"foo": "bar"}, + }, session) + }) + + t.Run("description=should pass through path, headers, method, and body to auth server", func(t *testing.T) { + testServer, requestRecorder := makeServiceServer(200, `{"subject": "123"}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("PUT", "/users/123?query=string", map[string]string{"sessionid": "zyx"}, "Test body"), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s", "use_original_method": true}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Len(t, requestRecorder.requests, 1) + + r := requestRecorder.requests[0] + + assert.Equal(t, r.Method, "PUT") + assert.Equal(t, r.URL.Path, "/users/123?query=string") + assert.Equal(t, r.Header.Get("sessionid"), "zyx") + assert.Equal(t, requestRecorder.bodies[0], []byte("Test body")) + assert.Equal(t, &AuthenticationSession{Subject: "123"}, session) + }) + + t.Run("description=should pass through path, headers, and body to auth server, and use POST method", func(t *testing.T) { + testServer, requestRecorder := makeServiceServer(200, `{"subject": "123"}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("PUT", "/users/123?query=string", map[string]string{"sessionid": "zyx"}, "Test body"), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s"}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Len(t, requestRecorder.requests, 1) + + r := requestRecorder.requests[0] + + assert.Equal(t, r.Method, "POST") + assert.Equal(t, r.URL.Path, "/users/123?query=string") + assert.Equal(t, r.Header.Get("sessionid"), "zyx") + assert.Equal(t, requestRecorder.bodies[0], []byte("Test body")) + assert.Equal(t, &AuthenticationSession{Subject: "123"}, session) + }) + + t.Run("description=should pass through path, headers, and body and use custom method to auth server", func(t *testing.T) { + testServer, requestRecorder := makeServiceServer(200, `{"subject": "123"}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("PUT", "/users/123?query=string", map[string]string{"sessionid": "zyx"}, "Test body"), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s", "method": "POST"}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Len(t, requestRecorder.requests, 1) + r := requestRecorder.requests[0] + assert.Equal(t, r.Method, "POST") + assert.Equal(t, r.URL.Path, "/users/123?query=string") + assert.Equal(t, r.Header.Get("sessionid"), "zyx") + assert.Equal(t, requestRecorder.bodies[0], []byte("Test body")) + assert.Equal(t, &AuthenticationSession{Subject: "123"}, session) + }) + + t.Run("description=should pass through method, headers and body to auth server when PreservePath is true (preserve the path from config remote URL)", func(t *testing.T) { + testServer, requestRecorder := makeServiceServer(200, `{"subject": "123"}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("PUT", "/users/123?query=string", map[string]string{"sessionid": "zyx"}, ""), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s", "preserve_path": true, "use_original_method": true}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Len(t, requestRecorder.requests, 1) + r := requestRecorder.requests[0] + assert.Equal(t, r.Method, "PUT") + assert.Equal(t, r.URL.Path, "/") + assert.Equal(t, r.Header.Get("sessionid"), "zyx") + assert.Equal(t, &AuthenticationSession{Subject: "123"}, session) + }) + + t.Run("description=should work with nested extra keys", func(t *testing.T) { + testServer, _ := makeServiceServer(200, `{"subject": "123", "session": {"foo": "bar"}}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("GET", "/", map[string]string{"sessionid": "zyx"}, ""), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s", "extra_from": "session"}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Equal(t, &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"foo": "bar"}, + }, session) + }) + + t.Run("description=should work with the root key for extra and a custom subject key", func(t *testing.T) { + testServer, _ := makeServiceServer(200, `{"identity": {"id": "123"}, "session": {"foo": "bar"}}`) + defer testServer.Close() + err := pipelineAuthenticator.Authenticate( + makeRemoteJSONRequest("GET", "/", map[string]string{"sessionid": "zyx"}, ""), + session, + json.RawMessage(fmt.Sprintf(`{"service_url": "%s", "subject_from": "identity.id", "extra_from": "@this"}`, testServer.URL)), + nil, + ) + require.NoError(t, err, "%#v", errors.Cause(err)) + assert.Equal(t, &AuthenticationSession{ + Subject: "123", + Extra: map[string]interface{}{"session": map[string]interface{}{"foo": "bar"}, "identity": map[string]interface{}{"id": "123"}}, + }, session) + }) + }) +} + +type RemoteJSONRequestRecorder struct { + requests []*http.Request + bodies [][]byte +} + +func makeServiceServer(statusCode int, responseBody string) (*httptest.Server, *RemoteJSONRequestRecorder) { + requestRecorder := &RemoteJSONRequestRecorder{} + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestRecorder.requests = append(requestRecorder.requests, r) + requestBody, _ := ioutil.ReadAll(r.Body) + requestRecorder.bodies = append(requestRecorder.bodies, requestBody) + w.WriteHeader(statusCode) + w.Write([]byte(responseBody)) + })) + return testServer, requestRecorder +} + +func makeRemoteJSONRequest(method string, path string, headers map[string]string, bodyStr string) *http.Request { + var body io.ReadCloser + header := http.Header{} + if bodyStr != "" { + body = ioutil.NopCloser(bytes.NewBufferString(bodyStr)) + header.Add("Content-Length", strconv.Itoa(len(bodyStr))) + } + req := &http.Request{ + Method: method, + URL: &url.URL{Path: path}, + Header: header, + Body: body, + } + for name, value := range headers { + req.Header.Add(name, value) + } + return req +} diff --git a/spec/config.schema.json b/spec/config.schema.json index 24e561a3d1..26e110f8c7 100644 --- a/spec/config.schema.json +++ b/spec/config.schema.json @@ -429,6 +429,51 @@ "required": ["check_session_url"], "additionalProperties": false }, + "configAuthenticatorsRemoteJSON": { + "type": "object", + "title": "RemoteJSON Request Authenticator Configuration", + "description": "This section is optional when the authenticator is disabled.", + "properties": { + "service_url": { + "title": "Service 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://service-host"] + }, + "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": "subject" + }, + "method": { + "title": "Method to pass to the auth server", + "description": "The method to pass to the auth server. Defaults to the original request method or to POST (see `use_original_method`).", + "type": "string", + "examples": ["GET"] + }, + "use_original_method": { + "title": "Use Original Method", + "description": "When set to true, the original request method is sent to the authentication service. If set to false, the POST method is used as a fallback. This flag is ignored when the `method` field is set.", + "type": "boolean", + "default": false + } + }, + "required": ["service_url"], + "additionalProperties": false + }, "configAuthenticatorsBearerToken": { "type": "object", "title": "Bearer Token Authenticator Configuration", @@ -1342,6 +1387,36 @@ } ] }, + "remote_json": { + "title": "Remote JSON Request", + "description": "The [`remote_json` authenticator](https://www.ory.sh/oathkeeper/docs/pipeline/authn#remote_json).", + "type": "object", + "properties": { + "enabled": { + "$ref": "#/definitions/handlerSwitch" + } + }, + "oneOf": [ + { + "properties": { + "enabled": { + "const": true + }, + "config": { + "$ref": "#/definitions/configAuthenticatorsRemoteJSON" + } + }, + "required": ["config"] + }, + { + "properties": { + "enabled": { + "const": false + } + } + } + ] + }, "bearer_token": { "title": "Bearer Token", "description": "The [`bearer_token` authenticator](https://www.ory.sh/oathkeeper/docs/pipeline/authn#bearer_token).", diff --git a/spec/pipeline/authenticators.remote_json.schema.json b/spec/pipeline/authenticators.remote_json.schema.json new file mode 100644 index 0000000000..cdabbe4e4b --- /dev/null +++ b/spec/pipeline/authenticators.remote_json.schema.json @@ -0,0 +1,5 @@ +{ + "$id": "/.schema/authenticators.remote_json.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "/.schema/config.schema.json#/definitions/configAuthenticatorsRemoteJSON" +}