// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package login_test import ( "context" "encoding/json" "io" "net/http" "testing" "time" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/gofrs/uuid" "github.com/ory/kratos/ui/node" "github.com/gobuffalo/httptest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/x/assertx" "github.com/ory/x/clock" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" "github.com/ory/herodot" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/pkg" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/text" "github.com/ory/kratos/x" ) type opts struct { loginChallenge string } func withLoginChallenge(challenge string) func(*opts) { return func(o *opts) { o.loginChallenge = challenge } } func TestHandleError(t *testing.T) { ctx := context.Background() conf, reg := pkg.NewFastRegistryWithMocks(t) public, _ := testhelpers.NewKratosServer(t, reg) reg.SetHydra(hydra.NewFake()) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/password.schema.json") router := http.NewServeMux() ts := httptest.NewServer(router) t.Cleanup(ts.Close) testhelpers.NewLoginUIFlowEchoServer(t, reg) testhelpers.NewErrorTestServer(t, reg) h := reg.LoginFlowErrorHandler() sdk := testhelpers.NewSDKClient(public) var loginFlow *login.Flow var flowError error var group node.UiNodeGroup router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) { h.WriteFlowError(w, r, loginFlow, "", group, flowError) }) reset := func() { loginFlow = nil flowError = nil group = "" } newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type, options ...func(*opts)) *login.Flow { req := &http.Request{URL: urlx.ParseOrPanic("/")} f, err := login.NewFlow(reg, req, ft) require.NoError(t, err) f.ExpiresAt = f.IssuedAt.Add(ttl) optsD := &opts{} for _, o := range options { o(optsD) } if optsD.loginChallenge != "" { f.OAuth2LoginChallenge = sqlxx.NullString(optsD.loginChallenge) } for _, s := range reg.LoginStrategies(context.Background()) { switch s := s.(type) { case login.UnifiedFormHydrator: require.NoError(t, s.PopulateLoginMethod(req, identity.AuthenticatorAssuranceLevel1, f)) case login.AAL1FormHydrator: require.NoError(t, s.PopulateLoginMethodFirstFactor(req, f)) } } require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), f)) return f } expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) { res, err := ts.Client().Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute() require.NoError(t, err) return sse.Error, nil } anHourAgo := time.Now().Add(-time.Hour) t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) t.Run("case=relative error", func(t *testing.T) { t.Cleanup(reset) reg.Config().MustSet(ctx, config.ViperKeySelfServiceErrorUI, "/login-ts") flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup assert.Regexp( t, "^/login-ts.*$", testhelpers.GetSelfServiceRedirectLocation(t, ts.URL+"/error"), ) }) t.Run("case=error with nil flow detects application/json", func(t *testing.T) { t.Cleanup(reset) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Header.Get("Content-Type"), "application/json") assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Contains(t, string(body), "system error") }) for _, tc := range []struct { n string t flow.Type }{ {"api", flow.TypeAPI}, {"spa", flow.TypeBrowser}, } { t.Run("flow="+tc.n, func(t *testing.T) { t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) group = node.PasswordGroup res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusGone, res.StatusCode, "%+v\n\t%s", res.Request, body) assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.GetBytes(body, "use_flow_id").String()) // "since" and "error.reason" both derive from time.Since(at), recomputed when the expected error is // built here — after the response was generated. Across a rounding boundary the two timings disagree, // so exclude both from the exact comparison and verify the reason template separately. assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(clock.New(), anHourAgo), json.RawMessage(body), []string{"since", "error.reason", "redirect_browser_to", "use_flow_id"}) assert.Regexp(t, `^The self-service flow expired \d+\.\d{2} minutes ago, initialize a new one\.$`, gjson.GetBytes(body, "error.reason").String()) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, tc.t) flowError = schema.NewInvalidCredentialsError() group = node.PasswordGroup res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusBadRequest, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body) assert.Equal(t, loginFlow.ID.String(), gjson.GetBytes(body, "id").String()) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, tc.t) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() require.Equal(t, http.StatusInternalServerError, res.StatusCode) body, err := io.ReadAll(res.Body) require.NoError(t, err) assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw) }) t.Run("case=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeInvalidLoginChallenge)) group = node.PasswordGroup flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error") res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body) require.Equal(t, "Unable to get OAuth 2.0 Login Challenge.", gjson.GetBytes(body, "error.reason").String(), "%s", body) }) t.Run("case=fetches hydra login request", func(t *testing.T) { t.Cleanup(reset) conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra") t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, nil) }) loginFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeValidLoginChallenge)) group = node.PasswordGroup flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error") res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body) require.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body) }) }) } t.Run("flow=browser", func(t *testing.T) { expectLoginUI := func(t *testing.T) (*login.Flow, *http.Response) { res, err := testhelpers.NewTestClient(t).Get(ts.URL + "/error") require.NoError(t, err) defer func() { _ = res.Body.Close() }() assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowLoginUI(ctx).String()+"?flow=") lf, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow"))) require.NoError(t, err) return lf, res } t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) loginFlow = &login.Flow{Type: flow.TypeBrowser} flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo) group = node.PasswordGroup lf, _ := expectLoginUI(t) require.Len(t, lf.UI.Messages, 1) assert.Equal(t, int(text.ErrorValidationLoginFlowExpired), int(lf.UI.Messages[0].ID)) }) t.Run("case=validation error", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = schema.NewInvalidCredentialsError() group = node.PasswordGroup lf, _ := expectLoginUI(t) require.NotEmpty(t, lf.UI.Nodes, x.MustEncodeJSON(t, lf)) require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf)) assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf)) }) t.Run("case=generic error", func(t *testing.T) { t.Cleanup(reset) loginFlow = newFlow(t, time.Minute, flow.TypeBrowser) flowError = herodot.ErrInternalServerError().WithReason("system error") group = node.PasswordGroup sse, _ := expectErrorUI(t) assertx.EqualAsJSON(t, flowError, sse) }) }) }