Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
321 lines
10 KiB
Go
321 lines
10 KiB
Go
// 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)
|
|
})
|
|
})
|
|
}
|