snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
39
selfservice/errorx/error.go
Normal file
39
selfservice/errorx/error.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
// swagger:model flowError
|
||||
type ErrorContainer struct {
|
||||
// ID of the error container.
|
||||
//
|
||||
// required: true
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
NID uuid.UUID `json:"-" db:"nid"`
|
||||
|
||||
// The error
|
||||
Errors json.RawMessage `json:"error" db:"errors"`
|
||||
|
||||
// CreatedAt is a helper struct field for gobuffalo.pop.
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// UpdatedAt is a helper struct field for gobuffalo.pop.
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
SeenAt sql.NullTime `json:"-" db:"seen_at"`
|
||||
WasSeen bool `json:"-" db:"was_seen"`
|
||||
CSRFToken string `db:"csrf_token" json:"-"`
|
||||
}
|
||||
|
||||
func (e ErrorContainer) TableName(ctx context.Context) string {
|
||||
return "selfservice_errors"
|
||||
}
|
||||
117
selfservice/errorx/handler.go
Normal file
117
selfservice/errorx/handler.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/nosurf"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
const RouteGet = "/self-service/errors"
|
||||
|
||||
var stub500, _ = json.Marshal(herodot.ErrInternalServerError().WithReasonf("This is a stub error."))
|
||||
|
||||
type (
|
||||
handlerDependencies interface {
|
||||
httpx.WriterProvider
|
||||
PersistenceProvider
|
||||
config.Provider
|
||||
}
|
||||
HandlerProvider interface {
|
||||
SelfServiceErrorHandler() *Handler
|
||||
}
|
||||
Handler struct {
|
||||
r handlerDependencies
|
||||
csrf nosurfx.CSRFToken
|
||||
}
|
||||
)
|
||||
|
||||
func NewHandler(
|
||||
r handlerDependencies,
|
||||
) *Handler {
|
||||
return &Handler{r: r, csrf: nosurf.Token}
|
||||
}
|
||||
|
||||
func (h *Handler) WithTokenGenerator(f func(r *http.Request) string) {
|
||||
h.csrf = f
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
|
||||
public.GET(RouteGet, h.publicFetchError)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterAdminRoutes(public *httprouterx.RouterAdmin) {
|
||||
public.GET(RouteGet, redir.RedirectToPublicRoute(h.r))
|
||||
}
|
||||
|
||||
// swagger:parameters getFlowError
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type getFlowError struct {
|
||||
// Error is the error's ID
|
||||
//
|
||||
// in: query
|
||||
// required: true
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/errors frontend getFlowError
|
||||
//
|
||||
// # Get User-Flow Errors
|
||||
//
|
||||
// This endpoint returns the error associated with a user-facing self service errors.
|
||||
//
|
||||
// This endpoint supports stub values to help you implement the error UI:
|
||||
//
|
||||
// - `?id=stub:500` - returns a stub 500 (Internal Server Error) error.
|
||||
//
|
||||
// More information can be found at [Ory Kratos User User Facing Error Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-facing-errors).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: flowError
|
||||
// 403: errorGeneric
|
||||
// 404: errorGeneric
|
||||
// 500: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-high
|
||||
func (h *Handler) publicFetchError(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.fetchError(w, r); err != nil {
|
||||
h.r.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) fetchError(w http.ResponseWriter, r *http.Request) error {
|
||||
id := cmp.Or(r.URL.Query().Get("error"), r.URL.Query().Get("id"))
|
||||
switch id {
|
||||
case "stub:500":
|
||||
h.r.Writer().Write(w, r, &ErrorContainer{ID: x.NewUUID(), Errors: stub500})
|
||||
return nil
|
||||
}
|
||||
|
||||
es, err := h.r.SelfServiceErrorPersister().ReadErrorContainer(r.Context(), x.ParseUUID(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.r.Writer().Write(w, r, es)
|
||||
return nil
|
||||
}
|
||||
133
selfservice/errorx/handler_test.go
Normal file
133
selfservice/errorx/handler_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/nosurf"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/errorsx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
h := errorx.NewHandler(reg)
|
||||
|
||||
t.Run("case=public authorization", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
ns := nosurfx.NewTestCSRFHandler(router, reg)
|
||||
|
||||
h.RegisterPublicRoutes(router)
|
||||
router.GET("/regen", func(w http.ResponseWriter, r *http.Request) {
|
||||
ns.RegenerateToken(w, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
router.GET("/set-error", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := reg.SelfServiceErrorPersister().CreateErrorContainer(context.Background(), nosurf.Token(r), herodot.ErrNotFound().WithReason("foobar"))
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write([]byte(id.String()))
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(ns)
|
||||
defer ts.Close()
|
||||
|
||||
getBody := func(t *testing.T, hc *http.Client, path string, expectedCode int) []byte {
|
||||
res, err := hc.Get(ts.URL + path)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
require.EqualValues(t, expectedCode, res.StatusCode)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
expectedError := x.MustEncodeJSON(t, herodot.ErrNotFound().WithReason("foobar"))
|
||||
|
||||
t.Run("call with valid csrf cookie", func(t *testing.T) {
|
||||
hc := testhelpers.NewTestClient(t)
|
||||
id := getBody(t, hc, "/set-error", http.StatusOK)
|
||||
actual := getBody(t, hc, errorx.RouteGet+"?id="+string(id), http.StatusOK)
|
||||
assert.JSONEq(t, expectedError, gjson.GetBytes(actual, "error").Raw, "%s", actual)
|
||||
|
||||
// We expect a forbid error if the error is not found, regardless of CSRF
|
||||
_ = getBody(t, hc, errorx.RouteGet+"?id=does-not-exist", http.StatusNotFound)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=stubs", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterPublicRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := ts.Client().Get(ts.URL + errorx.RouteGet + "?id=stub:500")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
actual, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, "This is a stub error.", gjson.GetBytes(actual, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=errors types", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterPublicRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
for k, tc := range []struct {
|
||||
gave error
|
||||
}{
|
||||
{gave: herodot.ErrNotFound().WithReason("foobar")},
|
||||
{gave: herodot.ErrNotFound().WithReason("foobar")},
|
||||
{gave: errors.WithStack(herodot.ErrNotFound().WithReason("foobar"))},
|
||||
{gave: errors.WithStack(herodot.ErrNotFound().WithReason("foobar").WithTrace(errors.New("asdf")))},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
csrf := x.NewUUID()
|
||||
id, err := reg.SelfServiceErrorPersister().CreateErrorContainer(context.Background(), csrf.String(), tc.gave)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := ts.Client().Get(ts.URL + errorx.RouteGet + "?id=" + id.String())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
actual, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
gg := errorsx.Cause(tc.gave)
|
||||
expected, err := json.Marshal(errorx.ErrorContainer{
|
||||
ID: id,
|
||||
Errors: x.RequireJSONMarshal(t, gg),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assertx.EqualAsJSONExcept(t, json.RawMessage(expected), json.RawMessage(actual), []string{"created_at", "updated_at"})
|
||||
assert.Empty(t, gjson.GetBytes(actual, "csrf_token").String())
|
||||
assert.JSONEq(t, string(x.RequireJSONMarshal(t, gg)), gjson.GetBytes(actual, "error").Raw)
|
||||
t.Logf("%s", actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
82
selfservice/errorx/manager.go
Normal file
82
selfservice/errorx/manager.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
type (
|
||||
managerDependencies interface {
|
||||
PersistenceProvider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
config.Provider
|
||||
}
|
||||
|
||||
Manager struct {
|
||||
d managerDependencies
|
||||
}
|
||||
|
||||
ManagementProvider interface {
|
||||
// SelfServiceErrorManager returns the errorx.Manager.
|
||||
SelfServiceErrorManager() *Manager
|
||||
}
|
||||
)
|
||||
|
||||
func NewManager(d managerDependencies) *Manager {
|
||||
return &Manager{d: d}
|
||||
}
|
||||
|
||||
// Create is a simple helper that saves all errors in the store and returns the
|
||||
// error url, appending the error ID.
|
||||
func (m *Manager) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) (string, error) {
|
||||
m.d.Logger().WithError(err).WithRequest(r).Errorf("An error occurred and is being forwarded to the error user interface.")
|
||||
|
||||
id, addErr := m.d.SelfServiceErrorPersister().CreateErrorContainer(ctx, m.d.GenerateCSRFToken(r), err)
|
||||
if addErr != nil {
|
||||
return "", addErr
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("id", id.String())
|
||||
|
||||
return urlx.CopyWithQuery(m.d.Config().SelfServiceFlowErrorURL(ctx), q).String(), nil
|
||||
}
|
||||
|
||||
// Forward is a simple helper that saves all errors in the store and forwards the HTTP Request
|
||||
// to the error url, appending the error ID.
|
||||
func (m *Manager) Forward(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
|
||||
if x.IsJSONRequest(r) {
|
||||
m.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
to, errCreate := m.Create(ctx, w, r, err)
|
||||
if errCreate != nil {
|
||||
// Everything failed. Resort to standard error output.
|
||||
m.d.Logger().WithError(errCreate).WithRequest(r).Error("Failed to create error container.")
|
||||
m.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if x.AcceptsJSON(r) {
|
||||
m.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
33
selfservice/errorx/persistence.go
Normal file
33
selfservice/errorx/persistence.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
Persister interface {
|
||||
// CreateErrorContainer adds an error to the manager and returns a
|
||||
// unique identifier or an error if insertion fails.
|
||||
CreateErrorContainer(ctx context.Context, csrfToken string, err error) (uuid.UUID, error)
|
||||
|
||||
// ReadErrorContainer returns an error by its unique identifier and
|
||||
// marks the error as read. If an error occurs during retrieval the
|
||||
// second return parameter is an error.
|
||||
ReadErrorContainer(ctx context.Context, id uuid.UUID) (*ErrorContainer, error)
|
||||
|
||||
// ClearErrorContainers clears read containers that are older than a
|
||||
// certain amount of time. If force is set to true, unread errors will
|
||||
// be cleared as well.
|
||||
ClearErrorContainers(ctx context.Context, olderThan time.Duration, force bool) error
|
||||
}
|
||||
|
||||
PersistenceProvider interface {
|
||||
SelfServiceErrorPersister() Persister
|
||||
}
|
||||
)
|
||||
88
selfservice/errorx/test/persistence.go
Normal file
88
selfservice/errorx/test/persistence.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package errorx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
|
||||
"github.com/ory/kratos/persistence"
|
||||
"github.com/ory/x/sqlcon"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
|
||||
toJSON := func(t *testing.T, in interface{}) string {
|
||||
out, err := json.Marshal(in)
|
||||
require.NoError(t, err)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
return func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
|
||||
|
||||
t.Run("case=not found", func(t *testing.T) {
|
||||
_, err := p.ReadErrorContainer(ctx, x.NewUUID())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=en- and decode properly", func(t *testing.T) {
|
||||
actualID, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.ReadErrorContainer(ctx, actualID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, `{"code":404,"status":"Not Found","reason":"foobar","message":"The requested resource could not be found"}`, gjson.Get(toJSON(t, actual), "error").String(), toJSON(t, actual))
|
||||
})
|
||||
|
||||
t.Run("case=clear", func(t *testing.T) {
|
||||
actualID, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = p.ReadErrorContainer(ctx, actualID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The row must age past the one-second cutoff before ClearErrorContainers
|
||||
// removes it, and ReadErrorContainer refreshes seen_at on every read, so
|
||||
// polling would keep resetting the age. Wait once instead. The extra 0.5s
|
||||
// covers MySQL's one-second timestamp resolution.
|
||||
time.Sleep(time.Second + time.Millisecond*500)
|
||||
require.NoError(t, p.ClearErrorContainers(ctx, time.Second, false))
|
||||
got, err := p.ReadErrorContainer(ctx, actualID)
|
||||
require.Error(t, err, "%+v", got)
|
||||
})
|
||||
|
||||
t.Run("case=network", func(t *testing.T) {
|
||||
t.Run("can not read error from another network", func(t *testing.T) {
|
||||
created, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, other := testhelpers.NewNetwork(t, ctx, p)
|
||||
_, err = other.ReadErrorContainer(ctx, created)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
t.Run("can not clear another network", func(t *testing.T) {
|
||||
_, other := testhelpers.NewNetwork(t, ctx, p)
|
||||
require.NoError(t, other.ClearErrorContainers(ctx, time.Second, true))
|
||||
|
||||
c, err := p.ReadErrorContainer(ctx, created)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(c.Errors), "foobar")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user