// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package hook_test import ( "context" "crypto/tls" _ "embed" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "slices" "strconv" "sync" "testing" "time" "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" "github.com/ory/kratos/request" "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/hook" "github.com/ory/kratos/session" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/kratos/x/events" "github.com/ory/x/jsonnetsecure" "github.com/ory/x/logrusx" "github.com/ory/x/otelx" "github.com/ory/x/otelx/semconv" "github.com/ory/x/snapshotx" ) var transientPayload = json.RawMessage(`{ "stuff": { "name": "fubar", "numbers": [42, 12345, 3.1415] } }`) type webHookDeps struct { x.BasicRegistry jsonnetsecure.VMProvider config.Provider } func newWebHookDeps(t *testing.T, logger *logrusx.Logger, reg *driver.RegistryDefault) *webHookDeps { t.Helper() return &webHookDeps{ BasicRegistry: x.BasicRegistry{L: logger, C: reg.HTTPClient(t.Context()), T: otelx.NewNoop()}, VMProvider: reg, Provider: reg, } } func TestWebHooks(t *testing.T) { logger := logrusx.New("kratos", "test") type WebHookRequest struct { Body string Headers http.Header Method string } webHookEndPoint := func(whr *WebHookRequest) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } whr.Body = string(body) whr.Headers = r.Header whr.Method = r.Method } } webHookHttpCodeEndPoint := func(code int) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) } } webHookHttpCodeWithBodyEndPoint := func(t *testing.T, code int, body []byte) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) _, err := w.Write(body) assert.NoError(t, err, "error while returning response from webHookHttpCodeWithBodyEndPoint") } } path := "/web_hook" newServer := func(f http.HandlerFunc) *httptest.Server { r := http.NewServeMux() r.HandleFunc("CONNECT "+path, f) r.HandleFunc("DELETE "+path, f) r.HandleFunc("GET "+path, f) r.HandleFunc("OPTIONS "+path, f) r.HandleFunc("PATCH "+path, f) r.HandleFunc("POST "+path, f) r.HandleFunc("PUT "+path, f) r.HandleFunc("TRACE "+path, f) ts := httptest.NewServer(r) t.Cleanup(ts.Close) return ts } bodyWithFlowOnly := func(req *http.Request, f flow.Flow) string { body := fmt.Sprintf(`{ "flow_id": "%s", "method": "%s", "url": "%s", "cookies": { "Some-Cookie-1": "Some-Cookie-Value", "Some-Cookie-2": "Some-other-Cookie-Value", "Some-Cookie-3": "Third-Cookie-Value" } }`, f.GetID(), req.Method, "http://www.ory.com/some_end_point") if len(req.Header) != 0 { if ua := req.Header.Get("User-Agent"); ua != "" { body, _ = sjson.Set(body, "headers.User-Agent", []string{ua}) } } return body } bodyWithFlowAndIdentityAndTransientPayload := func(req *http.Request, f flow.Flow, s *session.Session, tp json.RawMessage) string { body := fmt.Sprintf(`{ "flow_id": "%s", "identity_id": "%s", "method": "%s", "url": "%s", "cookies": { "Some-Cookie-1": "Some-Cookie-Value", "Some-Cookie-2": "Some-other-Cookie-Value", "Some-Cookie-3": "Third-Cookie-Value" }, "transient_payload": %s }`, f.GetID(), s.Identity.ID, req.Method, "http://www.ory.com/some_end_point", string(tp)) if len(req.Header) != 0 { if ua := req.Header.Get("User-Agent"); ua != "" { body, _ = sjson.Set(body, "headers.User-Agent", []string{ua}) } } return body } bodyWithFlowAndIdentityAndSessionAndTransientPayload := func(req *http.Request, f flow.Flow, s *session.Session, tp json.RawMessage) string { body := fmt.Sprintf(`{ "flow_id": "%s", "identity_id": "%s", "session_id": "%s", "method": "%s", "url": "%s", "cookies": { "Some-Cookie-1": "Some-Cookie-Value", "Some-Cookie-2": "Some-other-Cookie-Value", "Some-Cookie-3": "Third-Cookie-Value" }, "transient_payload": %s }`, f.GetID(), s.Identity.ID, s.ID, req.Method, "http://www.ory.com/some_end_point", string(tp)) if len(req.Header) != 0 { if ua := req.Header.Get("User-Agent"); ua != "" { body, _ = sjson.Set(body, "headers.User-Agent", []string{ua}) } } return body } conf, reg := pkg.NewFastRegistryWithMocks(t) conf.MustSet(t.Context(), config.ViperKeyWebhookHeaderAllowlist, []string{ "Accept", "Accept-Encoding", "Accept-Language", "Content-Length", "Content-Type", "Origin", "Priority", "Referer", "Sec-Ch-Ua", "Sec-Ch-Ua-Mobile", "Sec-Ch-Ua-Platform", "Sec-Fetch-Dest", "Sec-Fetch-Mode", "Sec-Fetch-Site", "Sec-Fetch-User", "True-Client-Ip", "User-Agent", "Valid-Header", }) for _, tc := range []struct { uc string callWebHook func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error expectedBody func(req *http.Request, f flow.Flow, s *session.Session) string createFlow func() flow.Flow }{ { uc: "Pre Login Hook", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteLoginPreHook(nil, req, f.(*login.Flow)) }, expectedBody: func(req *http.Request, f flow.Flow, _ *session.Session) string { return bodyWithFlowOnly(req, f) }, }, { uc: "Post Login Hook", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID(), TransientPayload: transientPayload} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteLoginPostHook(nil, req, node.PasswordGroup, f.(*login.Flow), s) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndSessionAndTransientPayload(req, f, s, transientPayload) }, }, { uc: "Pre Registration Hook", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteRegistrationPreHook(nil, req, f.(*registration.Flow)) }, expectedBody: func(req *http.Request, f flow.Flow, _ *session.Session) string { return bodyWithFlowOnly(req, f) }, }, { uc: "Post Registration Hook", createFlow: func() flow.Flow { return ®istration.Flow{ ID: x.NewUUID(), TransientPayload: transientPayload, } }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRegistrationPostPersistHook(nil, req, f.(*registration.Flow), s) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndTransientPayload(req, f, s, transientPayload) }, }, { uc: "Post Recovery Hook", createFlow: func() flow.Flow { return &recovery.Flow{ID: x.NewUUID(), TransientPayload: transientPayload} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRecoveryHook(nil, req, f.(*recovery.Flow), s) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndTransientPayload(req, f, s, transientPayload) }, }, { uc: "Post Verification Hook", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID(), TransientPayload: transientPayload} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndTransientPayload(req, f, s, transientPayload) }, }, { uc: "Post Settings Hook", createFlow: func() flow.Flow { return &settings.Flow{ID: x.NewUUID(), TransientPayload: transientPayload} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteSettingsPostPersistHook(nil, req, settings.PostHookPostPersistExecutorParams{ Flow: f.(*settings.Flow), Updated: s.Identity, Previous: s.Identity, Session: s, }) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndSessionAndTransientPayload(req, f, s, transientPayload) }, }, } { tc := tc t.Run("uc="+tc.uc, func(t *testing.T) { t.Parallel() for _, auth := range []struct { uc string authConfig request.AuthConfig expectedHeader func(header http.Header) }{ { uc: "no auth", authConfig: request.AuthConfig{}, expectedHeader: func(header http.Header) {}, }, { uc: "api key in header", authConfig: request.AuthConfig{Type: "api_key", Config: map[string]any{ "name": "My-Key", "value": "My-Key-Value", "in": "header", }}, expectedHeader: func(header http.Header) { header.Set("My-Key", "My-Key-Value") }, }, { uc: "api key in cookie", authConfig: request.AuthConfig{Type: "api_key", Config: map[string]any{ "name": "My-Key", "value": "My-Key-Value", "in": "cookie", }}, expectedHeader: func(header http.Header) { header.Set("Cookie", "My-Key=My-Key-Value") }, }, { uc: "basic auth", authConfig: request.AuthConfig{Type: "basic_auth", Config: map[string]any{ "user": "My-User", "password": "Super-Secret", }}, expectedHeader: func(header http.Header) { header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("My-User:Super-Secret"))) }, }, } { t.Run("auth="+auth.uc, func(t *testing.T) { for _, method := range []string{"CONNECT", "DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", "GARBAGE"} { t.Run("method="+method, func(t *testing.T) { f := tc.createFlow() req := (&http.Request{ Host: "www.ory.com", Header: map[string][]string{ "Some-Header": {"Some-Value"}, "User-Agent": {"Foo-Bar-Browser"}, "Invalid-Header": {"should be ignored"}, "Valid-Header": {"should not be ignored"}, "Cookie": {"Some-Cookie-1=Some-Cookie-Value; Some-Cookie-2=Some-other-Cookie-Value", "Some-Cookie-3=Third-Cookie-Value"}, }, RequestURI: "/some_end_point", Method: http.MethodPost, URL: &url.URL{Path: "/some_end_point"}, }).WithContext(t.Context()) cookie, err := req.Cookie("Some-Cookie-1") require.NoError(t, err) require.Equal(t, cookie.Name, "Some-Cookie-1") require.Equal(t, cookie.Value, "Some-Cookie-Value") cookie, err = req.Cookie("Some-Cookie-2") require.NoError(t, err) require.Equal(t, cookie.Name, "Some-Cookie-2") require.Equal(t, cookie.Value, "Some-other-Cookie-Value") cookie, err = req.Cookie("Some-Cookie-3") require.NoError(t, err) require.Equal(t, cookie.Name, "Some-Cookie-3") require.Equal(t, cookie.Value, "Third-Cookie-Value") s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} whr := &WebHookRequest{} ts := newServer(webHookEndPoint(whr)) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: method, URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", Auth: auth.authConfig, }) err = tc.callWebHook(wh, req, f, s) if method == "GARBAGE" { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, method, whr.Method) expectedHeader := http.Header{} expectedHeader.Set("Content-Type", "application/json") auth.expectedHeader(expectedHeader) for k, v := range expectedHeader { vals := whr.Headers.Values(k) assert.Equal(t, v, vals) } assert.NotZero(t, whr.Headers.Get("Ory-Webhook-Request-ID")) assert.NotZero(t, whr.Headers.Get("Ory-Webhook-Trigger-ID")) if method != "TRACE" { // According to the HTTP spec any request method, but TRACE is allowed to // have a body. Even this is a really bad practice for some of them, like for // GET assert.Zero(t, gjson.Get(whr.Body, "headers.Invalid-Header")) assert.NotZero(t, gjson.Get(whr.Body, "headers.Valid-Header")) whr.Body, err = sjson.Delete(whr.Body, "headers.Valid-Header") assert.NoError(t, err) assert.JSONEq(t, tc.expectedBody(req, f, s), whr.Body) } else { assert.Emptyf(t, whr.Body, "HTTP %s is not allowed to have a body", method) } }) } }) } }) } webHookResponse := []byte( `{ "messages": [{ "instance_ptr": "#/traits/username", "messages": [{ "id": 1234, "text": "error message", "type": "info" }] }] }`, ) webhookError := schema.NewValidationListError([]*schema.ValidationError{schema.NewHookValidationError("#/traits/username", "a webhook target returned an error", text.Messages{{ID: 1234, Type: "info", Text: "error message"}})}) for _, tc := range []struct { uc string callWebHook func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error webHookResponse func() (int, []byte) createFlow func() flow.Flow expectedError error }{ { uc: "Pre Login Hook - no block", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteLoginPreHook(nil, req, f.(*login.Flow)) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Pre Login Hook - block", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteLoginPreHook(nil, req, f.(*login.Flow)) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Login Hook - no block", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteLoginPostHook(nil, req, node.PasswordGroup, f.(*login.Flow), s) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Login Hook - block", createFlow: func() flow.Flow { return &login.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteLoginPostHook(nil, req, node.PasswordGroup, f.(*login.Flow), s) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Pre Registration Hook - no block", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteRegistrationPreHook(nil, req, f.(*registration.Flow)) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Pre Registration Hook - block", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, _ *session.Session) error { return wh.ExecuteRegistrationPreHook(nil, req, f.(*registration.Flow)) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Registration Post Persist Hook - no block", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRegistrationPostPersistHook(nil, req, f.(*registration.Flow), s) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Registration Post Persist Hook - block has no effect", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRegistrationPostPersistHook(nil, req, f.(*registration.Flow), s) }, // This would usually error, but post persist does not execute blocking web hooks, so we expect no error. webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: nil, }, { uc: "Post Registration Pre Persist Hook - no block", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRegistrationPrePersistHook(nil, req, f.(*registration.Flow), s.Identity) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Registration Pre Persist Hook - block", createFlow: func() flow.Flow { return ®istration.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRegistrationPrePersistHook(nil, req, f.(*registration.Flow), s.Identity) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Recovery Hook - no block", createFlow: func() flow.Flow { return &recovery.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRecoveryHook(nil, req, f.(*recovery.Flow), s) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Recovery Hook - block", createFlow: func() flow.Flow { return &recovery.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostRecoveryHook(nil, req, f.(*recovery.Flow), s) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Verification Hook - no block", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Verification Hook - block", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Settings Hook - no block", createFlow: func() flow.Flow { return &settings.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteSettingsPostPersistHook(nil, req, settings.PostHookPostPersistExecutorParams{ Flow: f.(*settings.Flow), Updated: s.Identity, Previous: s.Identity, Session: s, }) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} }, expectedError: nil, }, { uc: "Post Settings Hook Pre Persist - block", createFlow: func() flow.Flow { return &settings.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteSettingsPrePersistHook(nil, req, settings.PostHookPrePersistExecutorParams{ Flow: f.(*settings.Flow), Identity: s.Identity, Session: s, }) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: webhookError, }, { uc: "Post Settings Hook Post Persist - block has no effect", createFlow: func() flow.Flow { return &settings.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { return wh.ExecuteSettingsPostPersistHook(nil, req, settings.PostHookPostPersistExecutorParams{ Flow: f.(*settings.Flow), Updated: s.Identity, Previous: s.Identity, Session: s, }) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse }, expectedError: nil, }, } { tc := tc t.Run("uc="+tc.uc, func(t *testing.T) { t.Parallel() for _, method := range []string{"CONNECT", "DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"} { t.Run("method="+method, func(t *testing.T) { f := tc.createFlow() req := &http.Request{ Host: "www.ory.com", Header: map[string][]string{ "Some-Header": {"Some-Value"}, "X-Forwarded-Proto": {"https"}, "Cookie": {"Some-Cookie-1=Some-Cookie-Value; Some-Cookie-2=Some-other-Cookie-Value", "Some-Cookie-3=Third-Cookie-Value"}, }, RequestURI: "/some_end_point", Method: http.MethodPost, URL: &url.URL{ Path: "some_end_point", }, } s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} code, res := tc.webHookResponse() ts := newServer(webHookHttpCodeWithBodyEndPoint(t, code, res)) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: method, URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", CanInterrupt: true, }) err := tc.callWebHook(wh, req, f, s) if tc.expectedError == nil { assert.NoError(t, err) return } var validationError *schema.ValidationListError var expectedError *schema.ValidationListError if assert.ErrorAs(t, err, &validationError) && assert.ErrorAs(t, tc.expectedError, &expectedError) { assert.Equal(t, expectedError, validationError) } }) } }) } t.Run("update identity fields", func(t *testing.T) { t.Parallel() run := func(t *testing.T, id identity.Identity, responseCode int, response []byte) *identity.WithCredentialsAndAdminMetadataInJSON { f := ®istration.Flow{ID: x.NewUUID()} req := &http.Request{ Host: "www.ory.com", Header: map[string][]string{}, RequestURI: "/some_end_point", Method: http.MethodPost, URL: &url.URL{Path: "some_end_point"}, } ts := newServer(webHookHttpCodeWithBodyEndPoint(t, responseCode, response)) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "POST", URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", Response: request.ResponseConfig{ Parse: true, }, }) in := &id err := wh.ExecutePostRegistrationPrePersistHook(nil, req, f, in) require.NoError(t, err) result := identity.WithCredentialsAndAdminMetadataInJSON(*in) return &result } t.Run("case=update identity fields", func(t *testing.T) { expected := identity.Identity{ Credentials: map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: "password", Identifiers: []string{"test"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"}`), }, }, ExternalID: "original-external-id", SchemaID: "default", SchemaURL: "file://stub/default.schema.json", State: identity.StateActive, Traits: []byte(`{"email":"some@example.org"}`), VerifiableAddresses: []identity.VerifiableAddress{{ Value: "some@example.org", Verified: false, Via: "email", Status: identity.VerifiableAddressStatusPending, }}, RecoveryAddresses: []identity.RecoveryAddress{{ Value: "some@example.org", Via: "email", }}, MetadataPublic: []byte(`{"public":"data"}`), MetadataAdmin: []byte(`{"admin":"data"}`), } t.Run("case=body is empty", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity is present but empty", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated traits", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"}}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated state", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"state":"inactive"}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated public metadata", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"metadata_public":{"useful":"metadata"}}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated admin metadata", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"metadata_admin":{"useful":"admin metadata"}}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated verified addresses", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"},"verifiable_addresses":[{"value":"some@other-example.org","via":"email"}]}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated recovery addresses", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"},"recovery_addresses":[{"value":"some@other-example.org","via":"email"}]}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=identity has updated external_id", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"external_id":"updated-external-id"}}`)) snapshotx.SnapshotT(t, &actual) }) t.Run("case=unset external_id", func(t *testing.T) { actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"external_id":""}}`)) snapshotx.SnapshotT(t, &actual) }) }) }) t.Run("cannot have parse and ignore both set", func(t *testing.T) { t.Parallel() ts := newServer(webHookHttpCodeEndPoint(200)) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "GET", URL: ts.URL + path, TemplateURI: "./stub/test_body.jsonnet", Response: request.ResponseConfig{ Ignore: true, Parse: true, }, }) err := wh.ExecuteLoginPreHook(nil, req, f) assert.Error(t, err) }) for _, tc := range []struct { uc string parse bool }{ {uc: "Post Settings Hook - parse true", parse: true}, {uc: "Post Settings Hook - parse false", parse: false}, } { t.Run("uc="+tc.uc, func(t *testing.T) { t.Parallel() ts := newServer(webHookHttpCodeWithBodyEndPoint(t, 200, []byte(`{"identity":{"traits":{"email":"some@other-example.org"}}}`))) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &settings.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "POST", URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", Response: request.ResponseConfig{ Parse: tc.parse, }, }) uuid := x.NewUUID() in := &identity.Identity{ID: uuid} s := &session.Session{ID: x.NewUUID(), Identity: in} postPersistErr := wh.ExecuteSettingsPostPersistHook(nil, req, settings.PostHookPostPersistExecutorParams{ Flow: f, Updated: in, Previous: in, Session: s, }) assert.NoError(t, postPersistErr) assert.Equal(t, in, &identity.Identity{ID: uuid}) prePersistErr := wh.ExecuteSettingsPrePersistHook(nil, req, settings.PostHookPrePersistExecutorParams{ Flow: f, Identity: in, Session: s, }) assert.NoError(t, prePersistErr) if tc.parse == true { assert.Equal(t, in, &identity.Identity{ID: uuid, Traits: identity.Traits(`{"email":"some@other-example.org"}`)}) } else { assert.Equal(t, in, &identity.Identity{ID: uuid}) } }) } t.Run("must error when template is erroneous", func(t *testing.T) { t.Parallel() ts := newServer(webHookHttpCodeEndPoint(200)) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "POST", URL: ts.URL + path, TemplateURI: "file://./stub/bad_template.jsonnet", }) err := wh.ExecuteLoginPreHook(nil, req, f) assert.Error(t, err) }) t.Run("must not error when template is erroneous and responses are ignored", func(t *testing.T) { ts := newServer(webHookHttpCodeEndPoint(200)) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "GET", URL: ts.URL + path, TemplateURI: "file://./stub/bad_template.jsonnet", Response: request.ResponseConfig{ Ignore: true, }, }) err := wh.ExecuteLoginPreHook(nil, req, f) assert.NoError(t, err) }) t.Run("must not make request", func(t *testing.T) { t.Parallel() req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "POST", URL: "https://i-do-not-exist/", TemplateURI: "file://./stub/cancel_template.jsonnet", }) err := wh.ExecuteLoginPreHook(nil, req, f) assert.NoError(t, err) }) boolToString := func(f bool) string { if f { return " not" } else { return "" } } t.Run("ignores the response and is async", func(t *testing.T) { t.Parallel() var wg sync.WaitGroup wg.Add(1) waitTime := time.Millisecond * 100 ts := newServer(func(w http.ResponseWriter, _ *http.Request) { defer wg.Done() time.Sleep(waitTime) w.WriteHeader(http.StatusBadRequest) }) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "GET", URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", Response: request.ResponseConfig{ Ignore: true, }, }) start := time.Now() err := wh.ExecuteLoginPreHook(nil, req, f) assert.NoError(t, err) assert.Less(t, time.Since(start), waitTime) wg.Wait() }) t.Run("does not error on 500 request with retry", func(t *testing.T) { t.Parallel() // This test essentially ensures that we do not regress on the bug we had where 500 status code // would cause a retry, but because the body was incorrectly set we ended up with a ContentLength // error. var wg sync.WaitGroup wg.Add(3) // HTTP client does 3 attempts ts := newServer(func(w http.ResponseWriter, r *http.Request) { defer wg.Done() w.WriteHeader(500) _, _ = w.Write([]byte(`{"error":"some error"}`)) }) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "GET", URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", }) err := wh.ExecuteLoginPreHook(nil, req, f) require.Error(t, err) assert.NotContains(t, err.Error(), "ContentLength") wg.Wait() }) for _, tc := range []struct { code int mustSuccess bool }{ {200, true}, {299, true}, {300, true}, {399, true}, {400, false}, {499, false}, {500, false}, {599, false}, } { tc := tc t.Run("Must"+boolToString(tc.mustSuccess)+" error when end point is returning "+strconv.Itoa(tc.code), func(t *testing.T) { t.Parallel() ts := newServer(webHookHttpCodeEndPoint(tc.code)) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ Method: "POST", URL: ts.URL + path, TemplateURI: "file://./stub/test_body.jsonnet", }) err := wh.ExecuteLoginPreHook(nil, req, f) if tc.mustSuccess { assert.NoError(t, err) } else { assert.Error(t, err) } }) } } func TestDisallowPrivateIPRanges(t *testing.T) { t.Parallel() logger := logrusx.New("kratos", "test") mux := http.NewServeMux() mux.HandleFunc("/exception", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { t.Error("the request should not have reached the test server") w.WriteHeader(http.StatusOK) }) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} f := &login.Flow{ID: x.NewUUID()} t.Run("not allowed to call url", func(t *testing.T) { t.Parallel() conf, reg := pkg.NewFastRegistryWithMocks(t) conf.MustSet(t.Context(), config.ViperKeyClientHTTPNoPrivateIPRanges, true) conf.MustSet(t.Context(), config.ViperKeyClientHTTPPrivateIPExceptionURLs, []string{ts.URL + "/exception"}) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ URL: ts.URL, Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", }) err := wh.ExecuteLoginPostHook(nil, req, node.DefaultGroup, f, s) assert.ErrorContains(t, err, "no route to host") }) t.Run("allowed to call exempt url", func(t *testing.T) { t.Parallel() conf, reg := pkg.NewFastRegistryWithMocks(t) conf.MustSet(t.Context(), config.ViperKeyClientHTTPNoPrivateIPRanges, true) conf.MustSet(t.Context(), config.ViperKeyClientHTTPPrivateIPExceptionURLs, []string{ts.URL + "/exception"}) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ URL: ts.URL + "/exception", Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", }) err := wh.ExecuteLoginPostHook(nil, req, node.DefaultGroup, f, s) require.NoError(t, err) }) t.Run("not allowed to load from source", func(t *testing.T) { t.Parallel() req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} f := &login.Flow{ID: x.NewUUID()} conf, reg := pkg.NewFastRegistryWithMocks(t) conf.MustSet(t.Context(), config.ViperKeyClientHTTPNoPrivateIPRanges, true) conf.MustSet(t.Context(), config.ViperKeyClientHTTPPrivateIPExceptionURLs, []string{ts.URL + "/exception"}) whDeps := newWebHookDeps(t, logger, reg) wh := hook.NewWebHook(whDeps, &request.Config{ URL: ts.URL + "/exception", Method: "GET", TemplateURI: "http://192.168.178.0/test_body.jsonnet", }) err := wh.ExecuteLoginPostHook(nil, req, node.DefaultGroup, f, s) require.ErrorContains(t, err, "no route to host") }) } func TestAsyncWebhook(t *testing.T) { t.Parallel() _, reg := pkg.NewFastRegistryWithMocks(t) logger := logrusx.New("kratos", "test") logHook := new(test.Hook) logger.Logger.Hooks.Add(logHook) whDeps := newWebHookDeps(t, logger, reg) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } incomingCtx, incomingCancel := context.WithCancel(context.Background()) if deadline, ok := t.Deadline(); ok { // cancel this context one second before test timeout for clean shutdown var cleanup context.CancelFunc incomingCtx, cleanup = context.WithDeadline(incomingCtx, deadline.Add(-time.Second)) defer cleanup() } req = req.WithContext(incomingCtx) s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} f := &login.Flow{ID: x.NewUUID()} handlerEntered, blockHandlerOnExit := make(chan struct{}), make(chan struct{}) webhookReceiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(handlerEntered) <-blockHandlerOnExit _, _ = w.Write([]byte("ok")) })) t.Cleanup(webhookReceiver.Close) wh := hook.NewWebHook(whDeps, &request.Config{ URL: webhookReceiver.URL, Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", Response: request.ResponseConfig{ Ignore: true, }, }) err := wh.ExecuteLoginPostHook(nil, req, node.DefaultGroup, f, s) require.NoError(t, err) // execution returns immediately for async webhook select { case <-time.After(5 * time.Second): t.Fatal("timed out waiting for webhook request to reach test handler") case <-handlerEntered: // ok } // at this point, a goroutine is in the middle of the call to our test handler and waiting for a response incomingCancel() // simulate the incoming Kratos request having finished close(blockHandlerOnExit) timeout := time.After(1 * time.Second) var found bool for !found { for _, entry := range logHook.AllEntries() { if entry.Message == "Webhook request succeeded" { found = true break } } select { case <-timeout: t.Fatal("timed out waiting for successful webhook completion") case <-time.After(50 * time.Millisecond): // continue loop } } require.True(t, found) } func TestWebhookEvents(t *testing.T) { t.Parallel() _, reg := pkg.NewFastRegistryWithMocks(t) logger := logrusx.New("kratos", "test") whDeps := newWebHookDeps(t, logger, reg) req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} webhookReceiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ok" { w.WriteHeader(200) _, _ = w.Write([]byte("ok")) } else { w.WriteHeader(500) _, _ = w.Write([]byte("fail")) } })) t.Cleanup(webhookReceiver.Close) getAttributes := func(attrs []attribute.KeyValue) (webhookID, triggerID, requestID string) { for _, kv := range attrs { switch semconv.AttributeKey(kv.Key) { case events.AttributeKeyWebhookID: webhookID = kv.Value.Emit() case events.AttributeKeyWebhookTriggerID: triggerID = kv.Value.Emit() case events.AttributeKeyWebhookRequestID: requestID = kv.Value.Emit() } } return } t.Run("success", func(t *testing.T) { whID := x.NewUUID() wh := hook.NewWebHook(whDeps, &request.Config{ ID: whID.String(), URL: webhookReceiver.URL + "/ok", Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", }) recorder := tracetest.NewSpanRecorder() tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)).Tracer("test") ctx, span := tracer.Start(t.Context(), "parent") defer span.End() r1 := req.Clone(ctx) require.NoError(t, wh.ExecuteLoginPreHook(nil, r1, f)) ended := recorder.Ended() require.NotEmpty(t, ended) i := slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool { return sp.Name() == "selfservice.webhook" }) require.GreaterOrEqual(t, i, 0) evs := ended[i].Events() i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == events.WebhookDelivered.String() }) require.GreaterOrEqual(t, i, 0) actualWhID, deliveredTriggerID, deliveredRequestID := getAttributes(evs[i].Attributes) require.Equal(t, whID.String(), actualWhID) require.NotEmpty(t, deliveredTriggerID) require.NotEmpty(t, deliveredRequestID) assert.NotEqual(t, deliveredTriggerID, deliveredRequestID) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == events.WebhookSucceeded.String() }) require.GreaterOrEqual(t, i, 0) actualWhID, succeededTriggerID, _ := getAttributes(evs[i].Attributes) require.Equal(t, whID.String(), actualWhID) require.NotEmpty(t, succeededTriggerID) assert.Equal(t, deliveredTriggerID, succeededTriggerID) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == events.WebhookFailed.String() }) require.Equal(t, -1, i) }) t.Run("failed", func(t *testing.T) { whID := x.NewUUID() wh := hook.NewWebHook(whDeps, &request.Config{ ID: whID.String(), URL: webhookReceiver.URL + "/fail", Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", }) recorder := tracetest.NewSpanRecorder() tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)).Tracer("test") ctx, span := tracer.Start(t.Context(), "parent") defer span.End() r1 := req.Clone(ctx) require.Error(t, wh.ExecuteLoginPreHook(nil, r1, f)) ended := recorder.Ended() require.NotEmpty(t, ended) i := slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool { return sp.Name() == "selfservice.webhook" }) require.GreaterOrEqual(t, i, 0) evs := ended[i].Events() var deliveredEvents []sdktrace.Event deliveredTriggerIDs := map[string]struct{}{} deliveredRequestIDs := map[string]struct{}{} for _, ev := range evs { if ev.Name == events.WebhookDelivered.String() { deliveredEvents = append(deliveredEvents, ev) actualWhID, triggerID, requestID := getAttributes(ev.Attributes) require.Equal(t, whID.String(), actualWhID) require.NotEmpty(t, triggerID) require.NotEmpty(t, requestID) deliveredTriggerIDs[triggerID] = struct{}{} deliveredRequestIDs[requestID] = struct{}{} } } assert.Len(t, deliveredEvents, 3) assert.Len(t, deliveredTriggerIDs, 1) assert.Len(t, deliveredRequestIDs, 3) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == "WebhookFailed" }) require.GreaterOrEqual(t, i, 0) actualWhID, failedTriggerID, _ := getAttributes(evs[i].Attributes) require.Equal(t, whID.String(), actualWhID) require.NotEmpty(t, failedTriggerID) assert.Contains(t, deliveredTriggerIDs, failedTriggerID) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == "WebhookSucceeded" }) require.Equal(t, i, -1) }) t.Run("event disabled", func(t *testing.T) { wh := hook.NewWebHook(whDeps, &request.Config{ URL: webhookReceiver.URL + "/fail", Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", EmitAnalyticsEvent: new(false), }) recorder := tracetest.NewSpanRecorder() tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)).Tracer("test") ctx, span := tracer.Start(t.Context(), "parent") defer span.End() r1 := req.Clone(ctx) require.Error(t, wh.ExecuteLoginPreHook(nil, r1, f)) ended := recorder.Ended() require.NotEmpty(t, ended) i := slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool { return sp.Name() == "selfservice.webhook" }) require.GreaterOrEqual(t, i, 0) evs := ended[i].Events() i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == "WebhookDelivered" }) require.Equal(t, -1, i) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == "WebhookFailed" }) require.Equal(t, -1, i) i = slices.IndexFunc(evs, func(ev sdktrace.Event) bool { return ev.Name == "WebhookSucceeded" }) require.Equal(t, i, -1) }) } func TestWebhookHTTPClientSpan(t *testing.T) { t.Parallel() _, reg := pkg.NewFastRegistryWithMocks(t) logger := logrusx.New("kratos", "test") whDeps := newWebHookDeps(t, logger, reg) webhookReceiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) })) t.Cleanup(webhookReceiver.Close) wh := hook.NewWebHook(whDeps, &request.Config{ ID: x.NewUUID().String(), URL: webhookReceiver.URL, Method: "GET", TemplateURI: "file://stub/test_body.jsonnet", }) recorder := tracetest.NewSpanRecorder() tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)).Tracer("test") ctx, span := tracer.Start(t.Context(), "parent") defer span.End() req := &http.Request{ Header: map[string][]string{"Some-Header": {"Some-Value"}}, Host: "www.ory.com", TLS: new(tls.ConnectionState), URL: &url.URL{Path: "/some_end_point"}, Method: http.MethodPost, } f := &login.Flow{ID: x.NewUUID()} require.NoError(t, wh.ExecuteLoginPreHook(nil, req.Clone(ctx), f)) ended := recorder.Ended() i := slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool { return sp.Name() == "selfservice.webhook" }) require.GreaterOrEqual(t, i, 0) webhookSpan := ended[i] spanNames := make([]string, len(ended)) for i, sp := range ended { spanNames[i] = sp.Name() } i = slices.IndexFunc(ended, func(sp sdktrace.ReadOnlySpan) bool { return sp.SpanKind() == trace.SpanKindClient }) require.GreaterOrEqual(t, i, 0, "expected the HTTP client span of the webhook request to be ended and recorded, got only %v", spanNames) clientSpan := ended[i] assert.Equal(t, webhookSpan.SpanContext().TraceID(), clientSpan.SpanContext().TraceID(), "the HTTP client span must belong to the same trace as the selfservice.webhook span") assert.Equal(t, webhookSpan.SpanContext().SpanID(), clientSpan.Parent().SpanID(), "the HTTP client span must be a direct child of the selfservice.webhook span") } func TestRemoveDisallowedHeaders(t *testing.T) { t.Parallel() t.Run("empty http headers", func(t *testing.T) { headers := http.Header{} allowList := []string{"Content-Type", "Host"} newHeaders := hook.RemoveDisallowedHeaders(headers, allowList) require.Len(t, newHeaders, 0) }) t.Run("empty allow list", func(t *testing.T) { headers := http.Header{"Accept": {"application/json"}, "Content-Type": {"text/html"}} allowList := []string{} newHeaders := hook.RemoveDisallowedHeaders(headers, allowList) require.Len(t, newHeaders, 0) }) t.Run("all forbidden", func(t *testing.T) { headers := http.Header{"Accept": {"application/json"}, "Authorization": {"Bearer foo"}} allowList := []string{"Content-Type", "Host"} newHeaders := hook.RemoveDisallowedHeaders(headers, allowList) require.Len(t, newHeaders, 0) }) t.Run("general case", func(t *testing.T) { headers := http.Header{"Accept": {"application/json"}, "Content-Type": {"text/html"}} allowList := []string{"Content-Type", "Host"} newHeaders := hook.RemoveDisallowedHeaders(headers, allowList) require.Len(t, newHeaders, 1) h, present := newHeaders["Content-Type"] require.True(t, present) require.Equal(t, []string{"text/html"}, h) }) }