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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.email",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.name",
"type": "email",
"autocomplete": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.stringy",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.numby",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.booly",
"type": "checkbox",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_big_number",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_long_string",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "method",
"type": "submit",
"value": "profile",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,158 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.email",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.name",
"type": "email",
"autocomplete": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.stringy",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.numby",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.booly",
"type": "checkbox",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_big_number",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_long_string",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "method",
"type": "submit",
"value": "profile",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,158 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.email",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.name",
"type": "email",
"autocomplete": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.stringy",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.numby",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.booly",
"type": "checkbox",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_big_number",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_long_string",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "method",
"type": "submit",
"value": "profile",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,158 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.email",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.name",
"type": "email",
"autocomplete": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.stringy",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.numby",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.booly",
"type": "checkbox",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_big_number",
"type": "number",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.should_long_string",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "method",
"type": "submit",
"value": "profile",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"autocomplete": "new-password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,287 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"net/http"
"net/url"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x/events"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/swagger"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/urlx"
)
var ErrHookAbortFlow = errors.New("aborted settings hook execution")
type (
errorHandlerDependencies interface {
clock.Provider
config.Provider
errorx.ManagementProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
HandlerProvider
FlowPersistenceProvider
schema.IdentitySchemaProvider
}
ErrorHandlerProvider interface{ SettingsFlowErrorHandler() *ErrorHandler }
ErrorHandler struct {
d errorHandlerDependencies
}
)
// Is sent when a privileged session is required to perform the settings update.
//
// swagger:model needsPrivilegedSessionError
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type needsPrivilegedSessionError struct {
Error swagger.GenericError `json:"error"`
// Points to where to redirect the user to next.
//
// required: true
RedirectBrowserTo string `json:"redirect_browser_to"`
}
// FlowNeedsReAuth is sent when a privileged session is required to perform the settings update.
type FlowNeedsReAuth struct {
*herodot.DefaultError `json:"error"`
// Points to where to redirect the user to next.
RedirectBrowserTo string `json:"redirect_browser_to"`
}
func (e *FlowNeedsReAuth) EnhanceJSONError() interface{} {
return e
}
func NewFlowNeedsReAuth() *FlowNeedsReAuth {
return &FlowNeedsReAuth{
DefaultError: herodot.ErrForbidden().WithID(text.ErrIDNeedsPrivilegedSession).
WithReasonf("The login session is too old and thus not allowed to update these fields. Please re-authenticate."),
}
}
func NewErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}
func (s *ErrorHandler) reauthenticate(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
f *Flow,
err *FlowNeedsReAuth,
) {
returnTo := urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(ctx), r.URL.Path), r.URL.Query())
params := url.Values{}
params.Set("refresh", "true")
params.Set("return_to", returnTo.String())
redirectTo := urlx.AppendPaths(urlx.CopyWithQuery(s.d.Config().SelfPublicURL(ctx), params), login.RouteInitBrowserFlow).String()
err.RedirectBrowserTo = redirectTo
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
s.d.Writer().WriteError(w, r, err)
return
}
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
}
func (s *ErrorHandler) PrepareReplacementForExpiredFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, f *Flow, id *identity.Identity, sess *session.Session, err error) (*flow.ExpiredError, error) {
e := new(flow.ExpiredError)
if !errors.As(err, &e) {
return nil, nil
}
// create new flow because the old one is not valid
a, err := s.d.SettingsHandler().FromOldFlow(ctx, w, r, id, sess, *f)
if err != nil {
return nil, err
}
a.UI.Messages.Add(text.NewErrorValidationSettingsFlowExpired(s.d.Clock(), e.ExpiredAt))
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, a); err != nil {
return nil, err
}
return e.WithFlow(a), nil
}
func (s *ErrorHandler) WriteFlowError(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
group node.UiNodeGroup,
f *Flow,
id *identity.Identity,
sess *session.Session,
err error,
) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.ErrorHandler.WriteFlowError",
trace.WithAttributes(
attribute.String("error", err.Error()),
))
r = r.WithContext(ctx)
defer otelx.End(span, &err)
logger := s.d.Logger().
WithError(err).
WithRequest(r).
WithField("settings_flow", f.ToLoggerField())
logger.Info("Encountered self-service settings error.")
shouldRespondWithJSON := x.IsJSONRequest(r)
if f != nil {
span.SetAttributes(attribute.String("flow_id", f.ID.String()))
if f.Type == flow.TypeAPI {
shouldRespondWithJSON = true
}
}
if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
if shouldRespondWithJSON {
s.d.Writer().WriteError(w, r, err)
} else {
u := urlx.AppendPaths(s.d.Config().SelfPublicURL(ctx), login.RouteInitBrowserFlow)
http.Redirect(w, r, u.String(), http.StatusSeeOther)
}
return
}
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
if shouldRespondWithJSON {
s.d.Writer().WriteError(w, r, aalErr)
} else {
http.Redirect(w, r, aalErr.RedirectTo, http.StatusSeeOther)
}
return
}
if f == nil {
trace.SpanFromContext(ctx).AddEvent(events.NewSettingsFailed(ctx, uuid.Nil, "", "", err))
s.forward(ctx, w, r, nil, err)
return
}
trace.SpanFromContext(ctx).AddEvent(events.NewSettingsFailed(ctx, f.ID, string(f.Type), f.Active.String(), err))
if expired, inner := s.PrepareReplacementForExpiredFlow(ctx, w, r, f, id, sess, err); inner != nil {
s.forward(ctx, w, r, f, err)
return
} else if expired != nil {
if id == nil {
s.forward(ctx, w, r, f, err)
return
}
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
s.d.Writer().WriteError(w, r, expired)
} else {
http.Redirect(w, r, expired.GetFlow().AppendTo(s.d.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
}
return
}
if errors.Is(err, flow.ErrStrategyAsksToReturnToUI) {
if shouldRespondWithJSON {
s.d.Writer().Write(w, r, f)
} else {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
}
return
}
if e := new(FlowNeedsReAuth); errors.As(err, &e) {
s.reauthenticate(ctx, w, r, f, e)
return
}
if err := f.UI.ParseError(group, err); err != nil {
s.forward(ctx, w, r, f, err)
return
}
// Lookup the schema from the loaded configuration. This local schema
// URL is needed for sorting the UI nodes, instead of the public URL.
schemas, err := s.d.IdentityTraitsSchemas(ctx)
if err != nil {
s.forward(ctx, w, r, f, err)
return
}
schema, err := schemas.GetByID(id.SchemaID)
if err != nil {
s.forward(ctx, w, r, f, err)
return
}
if err := sortNodes(ctx, f.UI.Nodes, schema.RawURL); err != nil {
s.forward(ctx, w, r, f, err)
return
}
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, f); err != nil {
s.forward(ctx, w, r, f, err)
return
}
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
return
}
updatedFlow, innerErr := s.d.SettingsFlowPersister().GetSettingsFlow(ctx, f.ID)
if innerErr != nil {
s.forward(ctx, w, r, updatedFlow, innerErr)
}
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), updatedFlow)
}
func (s *ErrorHandler) forward(ctx context.Context, w http.ResponseWriter, r *http.Request, rr *Flow, err error) {
if rr == nil {
if x.IsJSONRequest(r) {
s.d.Writer().WriteError(w, r, err)
return
}
s.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
if rr.Type == flow.TypeAPI || x.IsJSONRequest(r) {
s.d.Writer().WriteErrorCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), err)
} else {
s.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
}
}

View File

@@ -0,0 +1,420 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/go-faker/faker/v4"
"github.com/gobuffalo/httptest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"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/settings"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/clock"
"github.com/ory/x/contextx"
"github.com/ory/x/urlx"
)
func TestHandleError(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
_ = testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
h := reg.SettingsFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var settingsFlow *settings.Flow
var flowError error
var flowMethod node.UiNodeGroup
var id identity.Identity
require.NoError(t, faker.FakeData(&id))
id.SchemaID = "default"
id.State = identity.StateActive
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &id))
req := httptest.NewRequest("GET", "/sessions/whoami", nil).WithContext(contextx.WithConfigValue(ctx, config.ViperKeySessionLifespan, time.Hour))
// This needs an authenticated client in order to call the RouteGetFlow endpoint
s, err := testhelpers.NewActiveSession(req, reg, &id, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
c := testhelpers.NewHTTPClientWithSessionToken(ctx, t, reg, s)
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(ctx, w, r, flowMethod, settingsFlow, &id, s, flowError)
})
router.HandleFunc("GET /fake-redirect", func(w http.ResponseWriter, r *http.Request) {
_, _, err := reg.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser)
require.NoError(t, err)
})
reset := func() {
settingsFlow = nil
flowError = nil
flowMethod = node.DefaultGroup
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *settings.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
f, err := settings.NewFlow(reg, req, &id, ft)
require.NoError(t, err)
for _, s := range reg.SettingsStrategies(context.Background()) {
require.NoError(t, s.PopulateSettingsMethod(ctx, req, &id, f))
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(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
}
expiredAnHourAgo := 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")
flowMethod = settings.StrategyProfile
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=error with nil flow detects application/json", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
flowMethod = settings.StrategyProfile
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)
settingsFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), expiredAnHourAgo)
flowMethod = settings.StrategyProfile
res, err := c.Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), ts.URL+"/error")
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(), expiredAnHourAgo), 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)
settingsFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
flowMethod = settings.StrategyProfile
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, settingsFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=return to UI error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = flow.ErrStrategyAsksToReturnToUI
flowMethod = settings.StrategyProfile
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, settingsFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=no active session", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = errors.WithStack(session.NewErrNoActiveSessionFound())
flowMethod = settings.StrategyProfile
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, session.NewErrNoActiveSessionFound().Reason(), gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=aal too low", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = errors.WithStack(session.NewErrAALNotSatisfied("a"))
flowMethod = settings.StrategyProfile
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusForbidden, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assertx.EqualAsJSON(t, session.NewErrAALNotSatisfied("a"), json.RawMessage(body))
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
flowMethod = settings.StrategyProfile
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("flow=browser", func(t *testing.T) {
expectSettingsUI := func(t *testing.T) (*settings.Flow, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowSettingsUI(ctx).String()+"?flow=")
sf, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return sf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = &settings.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), expiredAnHourAgo)
flowMethod = settings.StrategyProfile
lf, _ := expectSettingsUI(t)
require.Len(t, lf.UI.Messages, 1)
assert.Equal(t, int(text.ErrorValidationSettingsFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=return to ui error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = flow.ErrStrategyAsksToReturnToUI
flowMethod = settings.StrategyProfile
lf, _ := expectSettingsUI(t)
assert.EqualValues(t, settingsFlow.ID, lf.ID)
})
t.Run("case=no active session error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = errors.WithStack(session.NewErrNoActiveSessionFound())
flowMethod = settings.StrategyProfile
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
lf, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, lf.RequestedAAL)
})
t.Run("case=aal too low", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
settingsFlow.IdentityID = id.ID
flowError = errors.WithStack(session.NewErrAALNotSatisfied(conf.SelfServiceFlowLoginUI(ctx).String()))
flowMethod = settings.StrategyProfile
client := &http.Client{}
*client = *ts.Client()
// disable redirects
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
res, err := client.Get(ts.URL + "/error")
require.NoError(t, err)
loc, err := res.Location()
require.NoError(t, err)
// we should end up at the login URL since the NewALLNotSatisfied takes in a redirect to URL.
assert.Contains(t, loc.String(), loginTS.URL)
// test the JSON resoponse as well
request, err := http.NewRequest("GET", ts.URL+"/error", nil)
require.NoError(t, err)
request.Header.Add("Accept", "application/json")
res, err = client.Do(request)
require.NoError(t, err)
// the body should contain the reason for the error
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
require.NotEmpty(t, gjson.GetBytes(body, "error.reason").String(), "%s", body)
// We end up at the error endpoint with an aal2 error message because ts.client has no session.
assert.Equal(t, session.NewErrAALNotSatisfied("").Reason(), gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=session old error", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{urlx.AppendPaths(conf.SelfPublicURL(ctx), "/error").String()})
t.Cleanup(reset)
settingsFlow = &settings.Flow{Type: flow.TypeBrowser}
flowError = settings.NewFlowNeedsReAuth()
flowMethod = settings.StrategyProfile
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.GetProvider(ctx).String(config.ViperKeySelfServiceLoginUI))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
flowMethod = settings.StrategyProfile
lf, _ := expectSettingsUI(t)
require.NotEmpty(t, lf.UI, 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=inaccessible public URL", func(t *testing.T) {
t.Cleanup(reset)
// Since WriteFlowError is invoked directly by the /error handler above,
// manipulate the schema's URL directly to a bad URL.
id.SchemaURL = "http://some.random.url"
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
flowMethod = settings.StrategyProfile
lf, _ := expectSettingsUI(t)
require.NotEmpty(t, lf.UI, 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)
settingsFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
flowMethod = settings.StrategyProfile
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
})
}

View File

@@ -0,0 +1,261 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/redir"
"github.com/ory/pop/v6"
"github.com/ory/x/clock"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
// Flow represents a Settings Flow
//
// This flow is used when an identity wants to update settings
// (e.g. profile data, passwords, ...) in a selfservice manner.
//
// We recommend reading the [User Settings Documentation](../self-service/flows/user-settings)
//
// swagger:model settingsFlow
type Flow struct {
// ID represents the flow's unique ID. When performing the settings flow, this
// represents the id in the settings ui's query parameter: http://<selfservice.flows.settings.ui_url>?flow=<id>
//
// required: true
// type: string
// format: uuid
ID uuid.UUID `json:"id" db:"id" faker:"-"`
// Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction.
//
// required: true
Type flow.Type `json:"type" db:"type" faker:"flow_type"`
// ExpiresAt is the time (UTC) when the flow expires. If the user still wishes to update the setting,
// a new flow has to be initiated.
//
// required: true
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
// IssuedAt is the time (UTC) when the flow occurred.
//
// required: true
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
// RequestURL is the initial URL that was requested from Ory Kratos. It can be used
// to forward information contained in the URL's path or query for example.
//
// required: true
RequestURL string `json:"request_url" db:"request_url"`
// ReturnTo contains the requested return_to URL.
ReturnTo string `json:"return_to,omitempty" db:"-"`
// Active, if set, contains the registration method that is being used. It is initially
// not set.
Active sqlxx.NullString `json:"active,omitempty" db:"active_method"`
// UI contains data which must be shown in the user interface.
//
// required: true
UI *container.Container `json:"ui" db:"ui"`
// Identity contains the identity's data in raw form.
//
// If `state` is `success` this will be the updated identity!
//
// required: true
Identity *identity.Identity `json:"identity" faker:"identity" db:"-" belongs_to:"identities" fk_id:"IdentityID"`
// State represents the state of this flow. It knows two states:
//
// - show_form: No user data has been collected, or it is invalid, and thus the form should be shown.
// - success: Indicates that the settings flow has been updated successfully with the provided data.
// Done will stay true when repeatedly checking. If set to true, done will revert back to false only
// when a flow with invalid (e.g. "please use a valid phone number") data was sent.
//
// required: true
State State `json:"state" faker:"-" db:"state"`
// InternalContext stores internal context used by internals - for example MFA keys.
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
// IdentityID is a helper struct field for gobuffalo.pop.
IdentityID uuid.UUID `json:"-" faker:"-" db:"identity_id"`
// CreatedAt is a helper struct field for gobuffalo.pop.
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
OrganizationID uuid.NullUUID `json:"organization_id,omitempty" faker:"-" db:"organization_id"`
// Contains a list of actions, that could follow this flow
//
// It can, for example, contain a reference to the verification flow, created as part of the user's
// registration.
//
// required: false
ContinueWithItems []flow.ContinueWith `json:"continue_with,omitempty" db:"-" faker:"-" `
// TransientPayload is used to pass data from the settings flow to hooks and email templates
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
}
var (
_ flow.Flow = (*Flow)(nil)
_ flow.InternalContexter = (*Flow)(nil)
)
// flowDependencies are the dependencies NewFlow needs to construct a settings
// flow: the configuration (for the lifespan and return-to validation) and the
// clock.
type flowDependencies interface {
config.Provider
clock.Provider
}
func MustNewFlow(reg flowDependencies, r *http.Request, i *identity.Identity, ft flow.Type) *Flow {
f, err := NewFlow(reg, r, i, ft)
if err != nil {
panic(err)
}
return f
}
func NewFlow(reg flowDependencies, r *http.Request, i *identity.Identity, ft flow.Type) (*Flow, error) {
conf := reg.Config()
now := reg.Clock().Now().UTC()
exp := conf.SelfServiceFlowSettingsFlowLifespan(r.Context())
id := x.NewUUID()
// Pre-validate the return to URL which is contained in the HTTP request.
requestURL := x.RequestURL(r).String()
_, err := redir.SecureRedirectTo(r,
conf.SelfServiceBrowserDefaultReturnTo(r.Context()),
redir.SecureRedirectUseSourceURL(requestURL),
redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())),
)
if err != nil {
return nil, err
}
f := &Flow{
ID: id,
ExpiresAt: now.Add(exp),
IssuedAt: now,
RequestURL: requestURL,
IdentityID: i.ID,
Identity: i,
Type: ft,
State: flow.StateShowForm,
UI: &container.Container{
Method: "POST",
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
},
InternalContext: []byte("{}"),
}
if err := flow.SetRequestBaseURL(f, x.BaseURLStringFromContext(r.Context())); err != nil {
return nil, err
}
return f, nil
}
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
func (f *Flow) SetInternalContext(message sqlxx.JSONRawMessage) { f.InternalContext = message }
func (f *Flow) GetType() flow.Type { return f.Type }
func (f *Flow) GetRequestURL() string { return f.RequestURL }
func (Flow) TableName() string { return "selfservice_settings_flows" }
func (f Flow) GetID() uuid.UUID { return f.ID }
func (f *Flow) AppendTo(src *url.URL) *url.URL { return flow.AppendFlowTo(src, f.ID) }
func (f *Flow) GetUI() *container.Container { return f.UI }
func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems }
func (f *Flow) GetState() State { return f.State }
func (Flow) GetFlowName() flow.FlowName { return flow.SettingsFlow }
func (f *Flow) SetState(state State) { f.State = state }
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
func (f *Flow) Valid(c clock.Clock, s *session.Session) error {
if f.ExpiresAt.Before(c.Now().UTC()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
if f.IdentityID != s.Identity.ID {
return errors.WithStack(herodot.ErrForbidden().WithID(text.ErrIDInitiatedBySomeoneElse).WithReasonf(
"The request was initiated by someone else and has been blocked for security reasons. Please go back and try again."))
}
return nil
}
func (f *Flow) EnsureInternalContext() {
if !gjson.ParseBytes(f.InternalContext).IsObject() {
f.InternalContext = []byte("{}")
}
}
func (f Flow) MarshalJSON() ([]byte, error) {
type local Flow
f.SetReturnTo()
return json.Marshal(local(f))
}
func (f *Flow) SetReturnTo() {
// Return to is already set, do not overwrite it.
if len(f.ReturnTo) > 0 {
return
}
if u, err := url.Parse(f.RequestURL); err == nil {
f.ReturnTo = u.Query().Get("return_to")
}
}
func (f *Flow) AfterFind(*pop.Connection) error {
f.SetReturnTo()
return nil
}
func (f *Flow) AfterSave(*pop.Connection) error {
f.SetReturnTo()
return nil
}
func (f *Flow) AddContinueWith(c flow.ContinueWith) {
f.ContinueWithItems = append(f.ContinueWithItems, c)
}
func (f *Flow) ToLoggerField() map[string]any {
if f == nil {
return map[string]any{}
}
return map[string]any{
"id": f.ID.String(),
"return_to": f.ReturnTo,
"request_url": f.RequestURL,
"active": f.Active,
"Type": f.Type,
"nid": f.NID,
"state": f.State,
}
}

View File

@@ -0,0 +1,187 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/ory/x/clock"
"github.com/ory/x/jsonx"
"github.com/ory/kratos/pkg"
"github.com/go-faker/faker/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/urlx"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
func TestFakeFlow(t *testing.T) {
var r settings.Flow
require.NoError(t, faker.FakeData(&r))
assert.Equal(t, uuid.Nil, r.ID)
assert.NotEmpty(t, r.IssuedAt)
assert.NotEmpty(t, r.ExpiresAt)
assert.NotEmpty(t, r.RequestURL)
assert.NotEmpty(t, r.Active)
}
func TestNewFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
id := &identity.Identity{ID: x.NewUUID()}
t.Run("case=0", func(t *testing.T) {
r, err := settings.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("/"),
Host: "ory.sh", TLS: &tls.ConnectionState{}}, id, flow.TypeBrowser)
require.NoError(t, err)
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
assert.Equal(t, flow.TypeBrowser, r.Type)
assert.Equal(t, "https://ory.sh/", r.RequestURL)
})
t.Run("type=return_to", func(t *testing.T) {
_, err := settings.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, id, flow.TypeBrowser)
require.Error(t, err)
_, err = settings.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, id, flow.TypeBrowser)
require.NoError(t, err)
})
t.Run("case=1", func(t *testing.T) {
r, err := settings.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/?refresh=true"),
Host: "ory.sh"}, id, flow.TypeAPI)
require.NoError(t, err)
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
assert.Equal(t, flow.TypeAPI, r.Type)
assert.Equal(t, "http://ory.sh/?refresh=true", r.RequestURL)
})
t.Run("case=2", func(t *testing.T) {
r, err := settings.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("https://ory.sh/"),
Host: "ory.sh"}, id, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "https://ory.sh/", r.RequestURL)
})
}
func TestFlow(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
alice := x.NewUUID()
malice := x.NewUUID()
for k, tc := range []struct {
r *settings.Flow
exp time.Duration
s *session.Session
expectErr bool
}{
{
r: settings.MustNewFlow(
reg,
&http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"},
&identity.Identity{ID: alice},
flow.TypeBrowser),
exp: time.Hour,
s: &session.Session{Identity: &identity.Identity{ID: alice}},
},
{
r: settings.MustNewFlow(
reg,
&http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"},
&identity.Identity{ID: alice},
flow.TypeBrowser),
exp: time.Hour,
s: &session.Session{Identity: &identity.Identity{ID: malice}},
expectErr: true,
},
{
r: settings.MustNewFlow(
reg,
&http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"},
&identity.Identity{ID: alice},
flow.TypeBrowser),
exp: -time.Hour,
s: &session.Session{Identity: &identity.Identity{ID: alice}},
expectErr: true,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
tc.r.ExpiresAt = tc.r.IssuedAt.Add(tc.exp)
err := tc.r.Valid(clock.New(), tc.s)
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestGetType(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=%s", ft), func(t *testing.T) {
r := &settings.Flow{Type: ft}
assert.Equal(t, ft, r.GetType())
})
}
}
func TestGetRequestURL(t *testing.T) {
expectedURL := "http://foo/bar/baz"
f := &settings.Flow{RequestURL: expectedURL}
assert.Equal(t, expectedURL, f.GetRequestURL())
}
func TestEnsureInternalContext(t *testing.T) {
f := new(settings.Flow)
f.EnsureInternalContext()
assert.Equal(t, "{}", string(f.InternalContext))
}
func TestFlowEncodeJSON(t *testing.T) {
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &settings.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &settings.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, settings.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
}
func TestFlowDontOverrideReturnTo(t *testing.T) {
f := &settings.Flow{ReturnTo: "/foo"}
f.SetReturnTo()
assert.Equal(t, "/foo", f.ReturnTo)
f = &settings.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
f.SetReturnTo()
assert.Equal(t, "/bar", f.ReturnTo)
}

View File

@@ -0,0 +1,708 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"net/http"
"net/url"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"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/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/nosurf"
"github.com/ory/x/clock"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/urlx"
)
const (
RouteInitBrowserFlow = "/self-service/settings/browser"
RouteInitAPIFlow = "/self-service/settings/api"
RouteGetFlow = "/self-service/settings/flows"
RouteSubmitFlow = "/self-service/settings"
// RouteWellKnownChangePassword implements the W3C "change password URL"
// well-known location. Browsers and password managers use it to deep-link
// users to the page where they can change their password.
//
// See https://w3c.github.io/webappsec-change-password-url/.
RouteWellKnownChangePassword = "/.well-known/change-password"
ContinuityPrefix = "ory_kratos_settings"
)
func ContinuityKey(id string) string {
return ContinuityPrefix + "_" + id
}
type (
handlerDependencies interface {
clock.Provider
nosurfx.CSRFProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
config.Provider
session.HandlerProvider
session.ManagementProvider
identity.ValidationProvider
identity.ManagementProvider
identity.PrivilegedPoolProvider
errorx.ManagementProvider
continuity.ManagementProvider
x.CookieProvider
ErrorHandlerProvider
FlowPersistenceProvider
StrategyProvider
HookExecutorProvider
nosurfx.CSRFTokenGeneratorProvider
schema.IdentitySchemaProvider
login.HandlerProvider
}
HandlerProvider interface {
SettingsHandler() *Handler
}
Handler struct {
d handlerDependencies
csrf nosurfx.CSRFToken
}
)
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d, csrf: nosurf.Token} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
public.GET(RouteInitBrowserFlow, h.d.SessionHandler().IsAuthenticated(h.createBrowserSettingsFlow, func(w http.ResponseWriter, r *http.Request) {
if x.IsJSONRequest(r) {
h.d.Writer().WriteError(w, r, session.NewErrNoActiveSessionFound())
} else {
loginFlowUrl := h.d.Config().SelfPublicURL(r.Context()).JoinPath(login.RouteInitBrowserFlow).String()
redirectUrl, err := redir.TakeOverReturnToParameter(r.URL.String(), loginFlowUrl)
if err != nil {
http.Redirect(w, r, h.d.Config().SelfServiceFlowLoginUI(r.Context()).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, redirectUrl, http.StatusSeeOther)
}
}
}))
public.GET(RouteInitAPIFlow, h.d.SessionHandler().IsAuthenticated(h.createNativeSettingsFlow, nil))
public.GET(RouteGetFlow, h.d.SessionHandler().IsAuthenticated(h.getSettingsFlow, OnUnauthenticated(h.d)))
public.POST(RouteSubmitFlow, h.d.SessionHandler().IsAuthenticated(h.updateSettingsFlow, OnUnauthenticated(h.d)))
public.GET(RouteSubmitFlow, h.d.SessionHandler().IsAuthenticated(h.updateSettingsFlow, OnUnauthenticated(h.d)))
public.GET(RouteWellKnownChangePassword, h.redirectToChangePassword)
}
// swagger:route GET /.well-known/change-password frontend getWellKnownChangePassword
//
// # Change Password URL
//
// This endpoint implements the W3C "change password URL" well-known location by
// redirecting the browser to the configured settings UI. Password managers follow
// this redirect to take users straight to the page where they can change their
// password.
//
// Schemes: http, https
//
// Responses:
// 303: emptyResponse
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) redirectToChangePassword(w http.ResponseWriter, r *http.Request) {
to := h.d.Config().SelfServiceFlowSettingsUI(r.Context()).String()
if to == "" {
to = h.d.Config().SelfPublicURL(r.Context()).JoinPath(RouteInitBrowserFlow).String()
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteInitAPIFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteGetFlow, redir.RedirectToPublicRoute(h.d))
admin.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
}
func (h *Handler) NewFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, i *identity.Identity, s *session.Session, ft flow.Type) (_ *Flow, err error) {
ctx, span := h.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.Handler.NewFlow")
defer otelx.End(span, &err)
f, err := NewFlow(h.d, r, i, ft)
if err != nil {
return nil, err
}
if err := h.d.SettingsHookExecutor().PreSettingsHook(ctx, w, r, f, s); err != nil {
return nil, err
}
filters := PrepareOrganizations(r, f, i, h.d.Config().Organizations(ctx))
cookieStore := continuity.NewCookieReferenceStore(h.d.ContinuityCookieManager(ctx))
for _, strategy := range h.d.SettingsStrategies(ctx, filters...) {
if err := h.d.ContinuityManager().Abort(ctx, w, r, ContinuityKey(strategy.SettingsStrategyID()), cookieStore); err != nil {
return nil, err
}
if err := strategy.PopulateSettingsMethod(ctx, r, i, f); err != nil {
return nil, err
}
}
ds, err := h.d.Config().IdentityTraitsSchemaURL(ctx, i.SchemaID)
if err != nil {
return nil, err
}
if err := sortNodes(r.Context(), f.UI.Nodes, ds.String()); err != nil {
return nil, err
}
if err := h.d.SettingsFlowPersister().CreateSettingsFlow(r.Context(), f); err != nil {
return nil, err
}
return f, nil
}
func (h *Handler) FromOldFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, i *identity.Identity, s *session.Session, of Flow) (*Flow, error) {
nf, err := h.NewFlow(ctx, w, r, i, s, of.Type)
if err != nil {
return nil, err
}
nf.RequestURL = of.RequestURL
return nf, nil
}
// Create Native Settings Flow Parameters
//
// swagger:parameters createNativeSettingsFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createNativeSettingsFlow struct {
// The Session Token of the Identity performing the settings flow.
//
// in: header
SessionToken string `json:"X-Session-Token"`
// An optional organization ID that scopes the settings flow to providers of that organization.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
}
// swagger:route GET /self-service/settings/api frontend createNativeSettingsFlow
//
// # Create Settings Flow for Native Apps
//
// This endpoint initiates a settings flow for API clients such as mobile devices, smart TVs, and so on.
// You must provide a valid Ory Kratos Session Token for this endpoint to respond with HTTP 200 OK.
//
// To fetch an existing settings flow call `/self-service/settings/flows?flow=<flow_id>`.
//
// You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
// you vulnerable to a variety of CSRF attacks.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor or change the configuration.
//
// In the case of an error, the `error.id` of the JSON response body can be one of:
//
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `session_inactive`: No Ory Session was found - sign in a user first.
//
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
//
// Schemes: http, https
//
// Responses:
// 200: settingsFlow
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createNativeSettingsFlow(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s, err := h.d.SessionManager().FetchFromRequestContext(ctx, r)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.SessionManager().DoesSessionSatisfy(ctx, s, h.d.Config().SelfServiceSettingsRequiredAAL(ctx)); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
f, err := h.NewFlow(ctx, w, r, s.Identity, s, flow.TypeAPI)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, f)
}
// Create Browser Settings Flow Parameters
//
// swagger:parameters createBrowserSettingsFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserSettingsFlow struct {
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// HTTP Cookies
//
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
// An optional organization ID that scopes the settings flow to providers of that organization.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
}
// swagger:route GET /self-service/settings/browser frontend createBrowserSettingsFlow
//
// # Create Settings Flow for Browsers
//
// This endpoint initializes a browser-based user settings flow. Once initialized, the browser will be redirected to
// `selfservice.flows.settings.ui_url` with the flow ID set as the query parameter `?flow=`. If no valid
// Ory Kratos Session Cookie is included in the request, a login flow will be initialized.
//
// If this endpoint is opened as a link in the browser, it will be redirected to
// `selfservice.flows.settings.ui_url` with the flow ID set as the query parameter `?flow=`. If no valid user session
// was set, the browser will be redirected to the login endpoint.
//
// If this endpoint is called via an AJAX request, the response contains the settings flow without any redirects
// or a 401 forbidden error if no valid session was set.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor (happens automatically for server-side browser flows) or change the configuration.
//
// If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the
// case of an error, the `error.id` of the JSON response body can be one of:
//
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `session_inactive`: No Ory Session was found - sign in a user first.
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
//
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
//
// Schemes: http, https
//
// Responses:
// 200: settingsFlow
// 303: emptyResponse
// 400: errorGeneric
// 401: errorGeneric
// 403: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserSettingsFlow(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s, err := h.d.SessionManager().FetchFromRequestContext(ctx, r)
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
var managerOptions []session.ManagerOptions
requestURL := x.RequestURL(r)
if requestURL.Query().Get("return_to") != "" {
managerOptions = append(managerOptions, session.WithRequestURL(requestURL.String()))
}
if err := h.d.SessionManager().DoesSessionSatisfy(ctx, s, h.d.Config().SelfServiceSettingsRequiredAAL(ctx), managerOptions...); err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, nil, nil, nil, err)
return
}
f, err := h.NewFlow(ctx, w, r, s.Identity, s, flow.TypeBrowser)
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
redirTo := f.AppendTo(h.d.Config().SelfServiceFlowSettingsUI(ctx)).String()
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), f, redirTo)
}
// Get Settings Flow
//
// swagger:parameters getSettingsFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getSettingsFlow struct {
// ID is the Settings Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/settings?flow=abcde`).
//
// required: true
// in: query
ID string `json:"id"`
// The Session Token
//
// When using the SDK in an app without a browser, please include the
// session token here.
//
// in: header
SessionToken string `json:"X-Session-Token"`
// HTTP Cookies
//
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
}
// swagger:route GET /self-service/settings/flows frontend getSettingsFlow
//
// # Get Settings Flow
//
// When accessing this endpoint through Ory Kratos' Public API you must ensure that either the Ory Kratos Session Cookie
// or the Ory Kratos Session Token are set.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor or change the configuration.
//
// You can access this endpoint without credentials when using Ory Kratos' Admin API.
//
// If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the
// case of an error, the `error.id` of the JSON response body can be one of:
//
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `session_inactive`: No Ory Session was found - sign in a user first.
// - `security_identity_mismatch`: The flow was interrupted with `session_refresh_required` but apparently some other
// identity logged in instead.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: settingsFlow
// 401: errorGeneric
// 403: errorGeneric
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) getSettingsFlow(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rid := x.ParseUUID(r.URL.Query().Get("id"))
pr, err := h.d.SettingsFlowPersister().GetSettingsFlow(ctx, rid)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
sess, err := h.d.SessionManager().FetchFromRequestContext(ctx, r)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if pr.IdentityID != sess.Identity.ID {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().
WithID(text.ErrIDInitiatedBySomeoneElse).
WithReasonf("The request was made for another identity and has been blocked for security reasons.")))
return
}
// we cannot redirect back to the request URL (/self-service/settings/flows?id=...) since it would just redirect
// to a page displaying raw JSON to the client (browser), which is not what we want.
// Let's rather carry over the flow ID as a query parameter and redirect to the settings UI URL.
requestURL := urlx.CopyWithQuery(h.d.Config().SelfServiceFlowSettingsUI(ctx), url.Values{"flow": {rid.String()}})
if err := h.d.SessionManager().DoesSessionSatisfy(ctx, sess, h.d.Config().SelfServiceSettingsRequiredAAL(ctx), session.WithRequestURL(requestURL.String())); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if pr.ExpiresAt.Before(h.d.Clock().Now().UTC()) {
if pr.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(ctx, h.d.Config(), RouteInitBrowserFlow, pr.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
WithReason("The settings flow has expired. Redirect the user to the settings flow init endpoint to initialize a new settings flow.").
WithDetail("redirect_to", redirectURL.String()).
WithDetail("return_to", pr.ReturnTo)))
return
}
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
WithReason("The settings flow has expired. Call the settings flow init API endpoint to initialize a new settings flow.").
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), RouteInitAPIFlow).String())))
return
}
h.d.Writer().Write(w, r, pr)
}
// Update Settings Flow Parameters
//
// swagger:parameters updateSettingsFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateSettingsFlow struct {
// The Settings Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/settings?flow=abcde`).
//
// required: true
// in: query
Flow string `json:"flow"`
// in: body
// required: true
Body updateSettingsFlowBody
// The Session Token of the Identity performing the settings flow.
//
// in: header
SessionToken string `json:"X-Session-Token"`
// HTTP Cookies
//
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
}
// Update Settings Flow Request Body
//
// swagger:model updateSettingsFlowBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateSettingsFlowBody struct{}
// swagger:route POST /self-service/settings frontend updateSettingsFlow
//
// # Complete Settings Flow
//
// Use this endpoint to complete a settings flow by sending an identity's updated password. This endpoint
// behaves differently for API and browser flows.
//
// API-initiated flows expect `application/json` to be sent in the body and respond with
// - HTTP 200 and an application/json body with the session token on success;
// - HTTP 303 redirect to a fresh settings flow if the original flow expired with the appropriate error messages set;
// - HTTP 400 on form validation errors.
// - HTTP 401 when the endpoint is called without a valid session token.
// - HTTP 403 when `selfservice.flows.settings.privileged_session_max_age` was reached or the session's AAL is too low.
// Implies that the user needs to re-authenticate.
//
// Browser flows without HTTP Header `Accept` or with `Accept: text/*` respond with
// - a HTTP 303 redirect to the post/after settings URL or the `return_to` value if it was set and if the flow succeeded;
// - a HTTP 303 redirect to the Settings UI URL with the flow ID containing the validation errors otherwise.
// - a HTTP 303 redirect to the login endpoint when `selfservice.flows.settings.privileged_session_max_age` was reached or the session's AAL is too low.
//
// Browser flows with HTTP Header `Accept: application/json` respond with
// - HTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;
// - HTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;
// - HTTP 401 when the endpoint is called without a valid session cookie.
// - HTTP 403 when the page is accessed without a session cookie or the session's AAL is too low.
// - HTTP 400 on form validation errors.
//
// Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator
// Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn
// credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user
// to sign in with the second factor (happens automatically for server-side browser flows) or change the configuration.
//
// If this endpoint is called with a `Accept: application/json` HTTP header, the response contains the flow without a redirect. In the
// case of an error, the `error.id` of the JSON response body can be one of:
//
// - `session_refresh_required`: The identity requested to change something that needs a privileged session. Redirect
// the identity to the login init endpoint with query parameters `?refresh=true&return_to=<the-current-browser-url>`,
// or initiate a refresh login flow otherwise.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `session_inactive`: No Ory Session was found - sign in a user first.
// - `security_identity_mismatch`: The flow was interrupted with `session_refresh_required` but apparently some other
// identity logged in instead.
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
// - `browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.
// Most likely used in Social Sign In flows.
//
// More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings).
//
// Consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Security:
// sessionToken:
//
// Schemes: http, https
//
// Responses:
// 200: settingsFlow
// 303: emptyResponse
// 400: settingsFlow
// 401: errorGeneric
// 403: errorGeneric
// 410: errorGeneric
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-low
func (h *Handler) updateSettingsFlow(w http.ResponseWriter, r *http.Request) {
var (
err error
ctx = r.Context()
)
ctx, span := h.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.Handler.updateSettingsFlow")
defer otelx.End(span, &err)
rid, err := GetFlowID(r)
if err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, nil, nil, nil, err)
return
}
f, err := h.d.SettingsFlowPersister().GetSettingsFlow(ctx, rid)
if errors.Is(err, sqlcon.ErrNoRows()) {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, nil, nil, nil, errors.WithStack(herodot.ErrNotFound().WithReasonf("The settings request could not be found. Please restart the flow.")))
return
} else if err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, nil, nil, nil, err)
return
}
ss, err := h.d.SessionManager().FetchFromRequestContext(ctx, r)
if err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, nil, nil, err)
return
}
requestURL := x.RequestURL(r).String()
if err := h.d.SessionManager().DoesSessionSatisfy(ctx, ss, h.d.Config().SelfServiceSettingsRequiredAAL(ctx), session.WithRequestURL(requestURL)); err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, nil, nil, err)
return
}
if err := f.Valid(h.d.Clock(), ss); err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, ss.Identity, ss, err)
return
}
var s string
var updateContext *UpdateContext
for _, strat := range h.d.AllSettingsStrategies() {
uc, err := strat.Settings(ctx, w, r, f, ss)
if errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, strat.NodeGroup(), f, ss.Identity, ss, err)
return
}
s = strat.SettingsStrategyID()
updateContext = uc
break
}
if updateContext == nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, ss.Identity, ss, errors.WithStack(schema.NewNoSettingsStrategyResponsible()))
return
}
i, err := updateContext.GetIdentityToUpdate()
if err != nil {
// An identity to update must always be present.
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, ss.Identity, ss, err)
return
}
if err := h.d.SettingsHookExecutor().PostSettingsHook(ctx, w, r, s, updateContext, i); err != nil {
h.d.SettingsFlowErrorHandler().WriteFlowError(ctx, w, r, node.DefaultGroup, f, ss.Identity, ss, err)
return
}
}

View File

@@ -0,0 +1,33 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
)
func TestWellKnownChangePassword(t *testing.T) {
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsURL, "https://ui.example.com/settings")
public, _ := testhelpers.NewKratosServer(t, reg)
hc := &http.Client{Transport: testhelpers.NewTestTransport(t), CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := hc.Get(public.URL + "/.well-known/change-password")
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
assert.Equal(t, http.StatusSeeOther, res.StatusCode)
assert.Equal(t, "https://ui.example.com/settings", res.Header.Get("Location"))
}

View File

@@ -0,0 +1,750 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"time"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/assertx"
"github.com/ory/kratos/session"
"github.com/gofrs/uuid"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/corpx"
"github.com/ory/x/ioutilx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/urlx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/x"
)
func init() {
corpx.RegisterFakes()
}
func TestHandler(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "default", URL: "file://./stub/identity.schema.json", SelfserviceSelectable: true},
{ID: "not-default", URL: "file://./stub/multi-email.schema.json"},
})
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true)
testhelpers.StrategyEnable(t, conf, settings.StrategyProfile, true)
publicTS, _ := testhelpers.NewKratosServer(t, reg)
_ = testhelpers.NewSettingsUITestServer(t, conf)
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewSettingsUIFlowEchoServer(t, reg)
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
primaryIdentity := &identity.Identity{ID: x.NewUUID(), Traits: identity.Traits(`{}`)}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), primaryIdentity))
primaryUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, primaryIdentity)
otherUser := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
newExpiredFlow := func() *settings.Flow {
f, err := settings.NewFlow(reg,
&http.Request{URL: urlx.ParseOrPanic(publicTS.URL + login.RouteInitBrowserFlow)},
primaryIdentity, flow.TypeBrowser)
require.NoError(t, err)
f.ExpiresAt = f.IssuedAt.Add(-time.Minute)
return f
}
assertion := func(t *testing.T, body []byte, isApi bool) {
if isApi {
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
} else {
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
}
}
type options struct {
query url.Values
}
type initAuthOptions func(*options)
WithInitAuthQuery := func(q url.Values) initAuthOptions {
return func(o *options) {
o.query = q
}
}
initAuthenticatedFlow := func(t *testing.T, hc *http.Client, isAPI bool, isSPA bool, opts ...initAuthOptions) (*http.Response, []byte) {
op := new(options)
for _, o := range opts {
o(op)
}
route := settings.RouteInitBrowserFlow
if isAPI {
route = settings.RouteInitAPIFlow
}
reqURL, err := url.Parse(publicTS.URL + route)
require.NoError(t, err)
reqURL.RawQuery = op.query.Encode()
req := testhelpers.NewTestHTTPRequest(t, "GET", reqURL.String(), nil)
if isSPA || isAPI {
req.Header.Set("Accept", "application/json")
}
res, err := hc.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
if isAPI {
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
}
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
initFlow := func(t *testing.T, hc *http.Client, isAPI bool, opts ...initAuthOptions) (*http.Response, []byte) {
return initAuthenticatedFlow(t, hc, isAPI, false, opts...)
}
initSPAFlow := func(t *testing.T, hc *http.Client, opts ...initAuthOptions) (*http.Response, []byte) {
return initAuthenticatedFlow(t, hc, false, true, opts...)
}
aal2Identity := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, &identity.Identity{
State: identity.StateActive,
Traits: []byte(`{"email":"foo@bar"}`),
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), Identifiers: []string{"foo@bar"}},
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), Identifiers: []string{"foo@bar"}},
},
})
t.Run("endpoint=init", func(t *testing.T) {
t.Run("description=init a flow as API", func(t *testing.T) {
t.Run("description=without privileges", func(t *testing.T) {
res, body := initFlow(t, testhelpers.NewTestClient(t), true)
assert.Equalf(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
assert.Equalf(t, text.ErrIDNoActiveSession, gjson.GetBytes(body, "error.id").String(), "%s", body)
})
t.Run("description=success", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
res, body := initFlow(t, user1, true)
assert.Contains(t, res.Request.URL.String(), settings.RouteInitAPIFlow)
assertion(t, body, true)
snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").Raw), snapshotx.ExceptPaths(
"0.attributes.value",
))
})
t.Run("description=can not init if identity has aal2 but session has aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
res, body := initFlow(t, aal2Identity, true)
assert.Equalf(t, http.StatusForbidden, res.StatusCode, "%s", body)
assertx.EqualAsJSON(t,
session.NewErrAALNotSatisfied(publicTS.URL+"/self-service/login/browser?aal=aal2"),
json.RawMessage(body))
})
})
t.Run("description=init a flow as browser", func(t *testing.T) {
t.Run("case=unauthorized users are redirected to login preserving redirect_to param", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
returnTo := "?return_to=validRedirect"
req, err := http.NewRequest("GET", publicTS.URL+settings.RouteInitBrowserFlow+returnTo, nil)
require.NoError(t, err)
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
// here we check that the redirect status is 303
require.Equal(t, http.StatusSeeOther, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Equal(t, publicTS.URL+login.RouteInitBrowserFlow+returnTo, location.String())
})
t.Run("case=unauthorized users are redirected to login", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", publicTS.URL+settings.RouteInitBrowserFlow, nil)
require.NoError(t, err)
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
// here we check that the redirect status is 303
require.Equal(t, http.StatusSeeOther, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Equal(t, publicTS.URL+login.RouteInitBrowserFlow, location.String())
})
t.Run("description=success", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
res, body := initFlow(t, user1, false)
assert.Contains(t, res.Request.URL.String(), reg.Config().SelfServiceFlowSettingsUI(ctx).String())
assertion(t, body, false)
})
t.Run("description=can not init if identity has aal2 but session has aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
res, body := initFlow(t, aal2Identity, false)
assert.Contains(t, res.Request.URL.String(), reg.Config().SelfServiceFlowLoginUI(ctx).String())
assert.EqualValues(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
})
t.Run("description=settings return_to is persisted through aal2 flow", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
res, _ := initFlow(t, aal2Identity, false, WithInitAuthQuery(url.Values{"return_to": {"https://www.ory.com"}}))
assert.Contains(t, res.Request.URL.String(), reg.Config().SelfServiceFlowLoginUI(ctx).String())
lf, err := reg.LoginFlowPersister().GetLoginFlow(ctx, uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
reqURL, err := url.Parse(lf.RequestURL)
require.NoError(t, err)
// the return_to of login will be set to the full URL of the settings URL with its query parameters
settingsURL, err := url.Parse(publicTS.URL + settings.RouteInitBrowserFlow)
require.NoError(t, err)
q := settingsURL.Query()
q.Set("return_to", "https://www.ory.com")
settingsURL.RawQuery = q.Encode()
assert.Equal(t, settingsURL.String(), reqURL.Query().Get("return_to"))
})
t.Run("case=redirects with 303", func(t *testing.T) {
c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", publicTS.URL+settings.RouteInitBrowserFlow, nil)
require.NoError(t, err)
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
// here we check that the redirect status is 303
require.Equal(t, http.StatusSeeOther, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Contains(t, location.String(), conf.SelfServiceFlowSettingsUI(ctx).String())
})
})
t.Run("description=init a flow as SPA", func(t *testing.T) {
t.Run("description=without privileges", func(t *testing.T) {
res, body := initSPAFlow(t, testhelpers.NewTestClient(t))
assert.Equalf(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
assert.Equalf(t, text.ErrIDNoActiveSession, gjson.GetBytes(body, "error.id").String(), "%s", body)
})
t.Run("description=success", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
res, body := initSPAFlow(t, user1)
assert.Contains(t, res.Request.URL.String(), settings.RouteInitBrowserFlow)
assertion(t, body, false)
})
t.Run("description=can not init if identity has aal2 but session has aal1", func(t *testing.T) {
email := testhelpers.RandomEmail()
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, &identity.Identity{
State: identity.StateActive,
Traits: []byte(`{"email":"` + email + `"}`),
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), Identifiers: []string{email}},
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), Identifiers: []string{email}},
},
})
res, body := initSPAFlow(t, user1)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
url := url.URL{
Scheme: conf.SelfPublicURL(ctx).Scheme,
Host: conf.SelfPublicURL(ctx).Host,
Path: login.RouteInitBrowserFlow,
}
q := url.Query()
q.Add("aal", "aal2")
url.RawQuery = q.Encode()
assertx.EqualAsJSON(t, session.NewErrAALNotSatisfied(url.String()), json.RawMessage(body))
})
t.Run("description=settings return_to should persist through mfa flows", func(t *testing.T) {
email := testhelpers.RandomEmail()
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, &identity.Identity{
State: identity.StateActive,
Traits: []byte(`{"email":"` + email + `"}`),
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), Identifiers: []string{email}},
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), Identifiers: []string{email}},
},
})
res, body := initSPAFlow(t, user1, WithInitAuthQuery(url.Values{"return_to": {"https://www.ory.com/"}}))
assert.Equal(t, http.StatusForbidden, res.StatusCode)
returnToURL := gjson.GetBytes(body, "redirect_browser_to").String()
require.NotEmpty(t, returnToURL)
reqURL, err := url.Parse(gjson.GetBytes(body, "redirect_browser_to").String())
require.NoError(t, err)
settingsURL, err := url.Parse(publicTS.URL + settings.RouteInitBrowserFlow)
require.NoError(t, err)
q := settingsURL.Query()
q.Add("return_to", "https://www.ory.com/")
settingsURL.RawQuery = q.Encode()
assert.Equal(t, settingsURL.String(), reqURL.Query().Get("return_to"))
})
})
})
t.Run("case=multi-schema_endpoint=init", func(t *testing.T) {
t.Run("description=init a flow as API", func(t *testing.T) {
t.Run("description=success", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
})
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
// set the default schema to something else than the default
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "not-default")
res, body := initFlow(t, user1, true)
assert.Contains(t, res.Request.URL.String(), settings.RouteInitAPIFlow)
assertion(t, body, true)
snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").Raw), snapshotx.ExceptPaths(
"0.attributes.value",
))
})
})
t.Run("description=init a flow as browser", func(t *testing.T) {
t.Run("description=success", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
})
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
// set the default schema to something else than the default
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "not-default")
res, body := initFlow(t, user1, false)
assert.Contains(t, res.Request.URL.String(), reg.Config().SelfServiceFlowSettingsUI(ctx).String())
assertion(t, body, false)
snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").Raw), snapshotx.ExceptPaths(
"0.attributes.value",
))
})
})
t.Run("description=init a flow as SPA", func(t *testing.T) {
t.Run("description=success", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
})
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
// set the default schema to something else than the default
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "not-default")
res, body := initSPAFlow(t, user1)
assert.Contains(t, res.Request.URL.String(), settings.RouteInitBrowserFlow)
assertion(t, body, false)
snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").Raw), snapshotx.ExceptPaths(
"0.attributes.value",
))
})
})
})
t.Run("endpoint=fetch", func(t *testing.T) {
t.Run("description=fetching a non-existent flow should return a 404 error", func(t *testing.T) {
_, _, err := testhelpers.NewSDKCustomClient(publicTS, otherUser).FrontendAPI.GetSettingsFlow(context.Background()).Id("i-do-not-exist").Execute()
require.Error(t, err)
require.IsTypef(t, new(kratos.GenericOpenAPIError), err, "%T", err)
assert.Equal(t, int64(http.StatusNotFound), gjson.GetBytes(err.(*kratos.GenericOpenAPIError).Body(), "error.code").Int())
})
t.Run("description=fetching an expired flow returns 410", func(t *testing.T) {
pr := newExpiredFlow()
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(context.Background(), pr))
_, _, err := testhelpers.NewSDKCustomClient(publicTS, primaryUser).FrontendAPI.GetSettingsFlow(context.Background()).Id(pr.ID.String()).Execute()
require.Error(t, err)
require.IsTypef(t, new(kratos.GenericOpenAPIError), err, "%T", err)
assert.Equal(t, int64(http.StatusGone), gjson.GetBytes(err.(*kratos.GenericOpenAPIError).Body(), "error.code").Int())
})
t.Run("case=expired with return_to", func(t *testing.T) {
returnTo := "https://www.ory.com"
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
client := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
body := testhelpers.EasyGetBody(t, client, publicTS.URL+settings.RouteInitBrowserFlow+"?return_to="+returnTo)
// Expire the flow
f, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(context.Background(), f))
// Retrieve the flow and verify that return_to is in the response
getURL := fmt.Sprintf("%s%s?id=%s&return_to=%s", publicTS.URL, settings.RouteGetFlow, f.ID, returnTo)
getBody := testhelpers.EasyGetBody(t, client, getURL)
assert.Equal(t, gjson.GetBytes(getBody, "error.details.return_to").String(), returnTo)
// submit the flow but it is expired
u := publicTS.URL + settings.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := client.PostForm(u, url.Values{"method": {"password"}, "csrf_token": {"csrf"}, "password": {"password"}})
require.NoError(t, err)
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
f, err = reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
require.NoError(t, err)
assert.Equal(t, publicTS.URL+settings.RouteInitBrowserFlow+"?return_to="+returnTo, f.RequestURL)
})
t.Run("description=should fail to fetch request if identity changed", func(t *testing.T) {
t.Run("type=api", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
res, err := user1.Get(publicTS.URL + settings.RouteInitAPIFlow)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
body := ioutilx.MustReadAll(res.Body)
id := gjson.GetBytes(body, "id")
require.NotEmpty(t, id)
res, err = user2.Get(publicTS.URL + settings.RouteGetFlow + "?id=" + id.String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.EqualValues(t, res.StatusCode, http.StatusForbidden)
body = ioutilx.MustReadAll(res.Body)
assert.Containsf(t, gjson.GetBytes(body, "error.reason").String(), "The request was made for another identity and has been blocked for security reasons", "%s", body)
})
t.Run("type=browser", func(t *testing.T) {
res, err := primaryUser.Get(publicTS.URL + settings.RouteInitBrowserFlow)
require.NoError(t, err)
rid := res.Request.URL.Query().Get("flow")
require.NotEmpty(t, rid)
_, _, err = testhelpers.NewSDKCustomClient(publicTS, otherUser).FrontendAPI.GetSettingsFlow(context.Background()).Id(rid).Execute()
require.Error(t, err)
require.IsTypef(t, new(kratos.GenericOpenAPIError), err, "%T", err)
assert.Equal(t, int64(http.StatusForbidden), gjson.GetBytes(err.(*kratos.GenericOpenAPIError).Body(), "error.code").Int(), "should return a 403 error because the identities from the cookies do not match")
})
})
t.Run("description=can not fetch if identity has aal2 but session has aal1", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
})
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, "aal1")
res, body := initFlow(t, aal2Identity, false)
require.Equal(t, http.StatusOK, res.StatusCode)
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
flowID := gjson.GetBytes(body, "id").String()
settingsURL := publicTS.URL + settings.RouteGetFlow + "?id=" + flowID
res, err := aal2Identity.Get(settingsURL)
require.NoError(t, err)
body = ioutilx.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
url := url.URL{
Scheme: conf.SelfPublicURL(ctx).Scheme,
Host: conf.SelfPublicURL(ctx).Host,
Path: login.RouteInitBrowserFlow,
}
returnTo := conf.SelfServiceFlowSettingsUI(context.Background())
rq := returnTo.Query()
// the flow ID should be perserved within the return_to query parameter of the login flow
rq.Set("flow", flowID)
returnTo.RawQuery = rq.Encode()
q := url.Query()
q.Set("aal", "aal2")
q.Set("return_to", returnTo.String())
url.RawQuery = q.Encode()
require.EqualValues(t, http.StatusForbidden, res.StatusCode)
assertx.EqualAsJSON(t, session.NewErrAALNotSatisfied(url.String()), json.RawMessage(body))
})
})
t.Run("endpoint=submit", func(t *testing.T) {
t.Run("description=can not submit if identity has aal2 but session has aal1", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
})
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, "aal1")
res, body := initFlow(t, aal2Identity, false)
require.Equal(t, http.StatusOK, res.StatusCode)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
actual, res := testhelpers.SettingsMakeRequest(t, false, false, &f, aal2Identity, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "Please complete the second authentication challenge.", gjson.Get(actual, "ui.messages.0.text").String(), actual)
})
t.Run("type=spa", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
})
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, "aal1")
res, body := initFlow(t, aal2Identity, false)
require.Equal(t, http.StatusOK, res.StatusCode)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
actual, res := testhelpers.SettingsMakeRequest(t, false, true, &f, aal2Identity, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
url := url.URL{
Scheme: conf.SelfPublicURL(ctx).Scheme,
Host: conf.SelfPublicURL(ctx).Host,
Path: login.RouteInitBrowserFlow,
}
q := url.Query()
q.Set("aal", "aal2")
q.Set("return_to", publicTS.URL+"/self-service/settings?flow="+f.GetId())
url.RawQuery = q.Encode()
assert.Equal(t, url.String(), gjson.Get(actual, "redirect_browser_to").String(), actual)
})
t.Run("type=api", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
})
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, "aal1")
res, body := initFlow(t, aal2Identity, true)
require.Equal(t, http.StatusOK, res.StatusCode)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
actual, res := testhelpers.SettingsMakeRequest(t, true, false, &f, aal2Identity, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
url := url.URL{
Scheme: conf.SelfPublicURL(ctx).Scheme,
Host: conf.SelfPublicURL(ctx).Host,
Path: login.RouteInitBrowserFlow,
}
q := url.Query()
q.Set("aal", "aal2")
q.Set("return_to", publicTS.URL+"/self-service/settings?flow="+f.GetId())
url.RawQuery = q.Encode()
assert.Equal(t, url.String(), gjson.Get(actual, "redirect_browser_to").String(), actual)
})
})
t.Run("description=fail to submit form as another user", func(t *testing.T) {
t.Run("type=api", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
_, body := initFlow(t, user1, true)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, true, false, &f, user2, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Equal(t, "The request was initiated by someone else and has been blocked for security reasons. Please go back and try again.", gjson.Get(actual, "error.reason").String(), actual)
})
t.Run("type=spa", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
_, body := initFlow(t, user1, true)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, true, &f, user2, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Equal(t, "The request was initiated by someone else and has been blocked for security reasons. Please go back and try again.", gjson.Get(actual, "error.reason").String(), actual)
})
t.Run("type=browser", func(t *testing.T) {
user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
_, body := initFlow(t, user1, true)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, false, &f, user2, `{"method":"not-exists"}`)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Equal(t, "The request was initiated by someone else and has been blocked for security reasons. Please go back and try again.", gjson.Get(actual, "error.reason").String(), actual)
})
})
t.Run("description=submit - kratos session cookie issued", func(t *testing.T) {
t.Run("type=spa", func(t *testing.T) {
_, body := initFlow(t, primaryUser, false)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, true, &f, primaryUser, fmt.Sprintf(`{"method":"profile", "numby": 15, "csrf_token": "%s"}`, nosurfx.FakeCSRFToken))
require.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL))), "ory_kratos_session")
assert.Equal(t, "Your changes have been saved!", gjson.Get(actual, "ui.messages.0.text").String(), actual)
})
t.Run("type=browser", func(t *testing.T) {
_, body := initFlow(t, primaryUser, false)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, false, &f, primaryUser, `method=profile&traits.numby=15&csrf_token=`+nosurfx.FakeCSRFToken)
assert.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL))), "ory_kratos_session")
assert.Equal(t, "Your changes have been saved!", gjson.Get(actual, "ui.messages.0.text").String(), actual)
})
})
})
t.Run("case=multi-schema_endpoint=submit", func(t *testing.T) {
t.Run("description=fail to submit form with invalid data", func(t *testing.T) {
for _, tc := range []struct {
name string
isAPI bool
isSPA bool
}{
{
name: "api",
isAPI: true,
}, {
name: "spa",
isSPA: true,
}, {
name: "browser",
},
} {
t.Run("type="+tc.name, func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
})
user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
// set the default schema to something else than the default
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "not-default")
_, body := initFlow(t, user1, true)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
_, res := testhelpers.SettingsMakeRequest(t, tc.isAPI, tc.isSPA, &f, user1, `{"should_long_string":"tooshort"}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
}
})
t.Run("description=submit - kratos session cookie issued", func(t *testing.T) {
t.Run("type=spa", func(t *testing.T) {
_, body := initFlow(t, primaryUser, false)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, true, &f, primaryUser, fmt.Sprintf(`{"method":"profile", "numby": 15, "csrf_token": "%s"}`, nosurfx.FakeCSRFToken))
require.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL))), "ory_kratos_session")
assert.Equal(t, "Your changes have been saved!", gjson.Get(actual, "ui.messages.0.text").String(), actual)
})
t.Run("type=browser", func(t *testing.T) {
_, body := initFlow(t, primaryUser, false)
var f kratos.SettingsFlow
require.NoError(t, json.Unmarshal(body, &f))
actual, res := testhelpers.SettingsMakeRequest(t, false, false, &f, primaryUser, `method=profile&traits.numby=15&csrf_token=`+nosurfx.FakeCSRFToken)
assert.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", primaryUser.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL))), "ory_kratos_session")
assert.Equal(t, "Your changes have been saved!", gjson.Get(actual, "ui.messages.0.text").String(), actual)
})
})
})
t.Run("case=relative redirect when self-service settings ui is a relative url", func(t *testing.T) {
reg.Config().MustSet(ctx, config.ViperKeySelfServiceSettingsURL, "/settings-ts")
user1 := testhelpers.NewNoRedirectHTTPClientWithArbitrarySessionCookie(ctx, t, reg)
res, _ := initFlow(t, user1, false)
assert.Regexp(
t,
"^/settings-ts.*$",
res.Header.Get("Location"),
)
})
}

View File

@@ -0,0 +1,558 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
stderrors "errors"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/pop/v6"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/schema"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/transaction"
)
type (
PreHookExecutorParams struct {
Flow *Flow
Session *session.Session
}
PreHookExecutor interface {
ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error
}
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error
PostHookPrePersistExecutorParams struct {
Flow *Flow
Identity *identity.Identity
Session *session.Session
}
PostHookPrePersistExecutor interface {
ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error
}
PostHookPrePersistExecutorFunc func(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error
PostHookPostPersistExecutorParams struct {
Flow *Flow
Previous *identity.Identity
Updated *identity.Identity
Session *session.Session
}
PostHookPostPersistExecutor interface {
ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error
}
PostHookPostPersistExecutorFunc func(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error
HooksProvider interface {
PreSettingsHooks(ctx context.Context) ([]PreHookExecutor, error)
PostSettingsPrePersistHooks(ctx context.Context, settingsType string) ([]PostHookPrePersistExecutor, error)
PostSettingsPostPersistHooks(ctx context.Context, settingsType string) ([]PostHookPostPersistExecutor, error)
}
executorDependencies interface {
identity.ManagementProvider
identity.ValidationProvider
identity.PoolProvider
identity.PrivilegedPoolProvider
identity.PendingTraitsChangePersistenceProvider
session.ManagementProvider
session.PersistenceProvider
config.Provider
HandlerProvider
HooksProvider
FlowPersistenceProvider
nosurfx.CSRFTokenGeneratorProvider
logrusx.Provider
httpx.WriterProvider
otelx.Provider
transaction.PersistenceProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
SettingsHookExecutor() *HookExecutor
}
)
func (f PreHookExecutorFunc) ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error {
return f(w, r, params)
}
func (f PostHookPrePersistExecutorFunc) ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error {
return f(w, r, params)
}
func (f PostHookPostPersistExecutorFunc) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error {
return f(w, r, params)
}
func PostHookPostPersistExecutorNames(e []PostHookPostPersistExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func PostHookPrePersistExecutorNames(e []PostHookPrePersistExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func NewHookExecutor(d executorDependencies) *HookExecutor {
return &HookExecutor{d: d}
}
type PostSettingsHookOption func(o *postSettingsHookOptions)
type postSettingsHookOptions struct {
cb func(ctxUpdate *UpdateContext) error
}
func WithCallback(cb func(ctxUpdate *UpdateContext) error) func(o *postSettingsHookOptions) {
return func(o *postSettingsHookOptions) {
o.cb = cb
}
}
func (e *HookExecutor) handleSettingsError(_ context.Context, _ http.ResponseWriter, r *http.Request, settingsType string, f *Flow, i *identity.Identity, flowError error) error {
if f != nil {
if i != nil {
var group node.UiNodeGroup
switch settingsType {
case "password":
group = node.PasswordGroup
case "oidc":
group = node.OpenIDConnectGroup
}
cont, err := container.NewFromStruct("", group, i.Traits, "traits")
if err != nil {
e.d.Logger().WithError(err).Error("could not update flow UI")
return err
}
for _, n := range cont.Nodes {
// we only set the value and not the whole field because we want to keep types from the initial form generation
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
}
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(e.d.GenerateCSRFToken(r))
}
}
return flowError
}
func (e *HookExecutor) PostSettingsHook(ctx context.Context, w http.ResponseWriter, r *http.Request, settingsType string, ctxUpdate *UpdateContext, i *identity.Identity, opts ...PostSettingsHookOption) (err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.PostSettingsHook")
defer otelx.End(span, &err)
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("Running PostSettingsPrePersistHooks.")
// Verify the redirect URL before we do any other processing.
c := e.d.Config()
returnTo, err := redir.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectUseSourceURL(ctxUpdate.Flow.RequestURL),
redir.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(
e.d.Config().SelfServiceFlowSettingsReturnTo(ctx, settingsType,
ctxUpdate.Flow.AppendTo(e.d.Config().SelfServiceFlowSettingsUI(ctx)))),
)
if err != nil {
return err
}
hookOptions := new(postSettingsHookOptions)
for _, f := range opts {
f(hookOptions)
}
preHooks, err := e.d.PostSettingsPrePersistHooks(ctx, settingsType)
if err != nil {
return err
}
for k, executor := range preHooks {
logFields := logrus.Fields{
"executor": fmt.Sprintf("%T", executor),
"executor_position": k,
"executors": PostHookPrePersistExecutorNames(preHooks),
"identity_id": i.ID,
"flow_method": settingsType,
}
if err := executor.ExecuteSettingsPrePersistHook(w, r, PostHookPrePersistExecutorParams{
Flow: ctxUpdate.Flow,
Identity: i,
Session: ctxUpdate.Session,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("A ExecuteSettingsPrePersistHook hook aborted early.")
return nil
}
var group node.UiNodeGroup
switch settingsType {
case "password":
group = node.PasswordGroup
case "oidc":
group = node.OpenIDConnectGroup
}
return flow.HandleHookError(w, r, ctxUpdate.Flow, i.Traits, group, err, e.d, e.d)
}
e.d.Logger().WithRequest(r).WithFields(logFields).Debug("ExecuteSettingsPrePersistHook completed successfully.")
}
options := []identity.ManagerOption{identity.ManagerExposeValidationErrorsForInternalTypeAssertion}
if e.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) {
options = append(options, identity.ManagerAllowWriteProtectedTraits)
}
if len(ctxUpdate.excludeCredentialTypesFromUpdate) > 0 {
options = append(options, identity.ManagerWithoutCredentialTypes(ctxUpdate.excludeCredentialTypesFromUpdate...))
}
if err := e.d.IdentityManager().Update(ctx, i, options...); err != nil {
if errors.Is(err, identity.ErrProtectedFieldModified()) {
e.d.Logger().WithError(err).Debug("Modifying protected field requires re-authentication.")
return errors.WithStack(NewFlowNeedsReAuth())
}
if errors.Is(err, sqlcon.ErrUniqueViolation()) {
return schema.NewDuplicateCredentialsError(err)
}
return err
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
Debug("An identity's settings have been updated.")
ctxUpdate.UpdateIdentity(i)
ctxUpdate.Flow.State = flow.StateSuccess
if hookOptions.cb != nil {
if err := hookOptions.cb(ctxUpdate); err != nil {
return err
}
}
newFlow, err := e.d.SettingsHandler().NewFlow(ctx, w, r, i, ctxUpdate.Session, ctxUpdate.Flow.Type)
if err != nil {
return err
}
ctxUpdate.Flow.UI = newFlow.UI
ctxUpdate.Flow.UI.ResetMessages()
ctxUpdate.Flow.UI.AddMessage(node.DefaultGroup, text.NewInfoSelfServiceSettingsUpdateSuccess())
ctxUpdate.Flow.InternalContext = newFlow.InternalContext
if err := e.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
return err
}
postHooks, err := e.d.PostSettingsPostPersistHooks(ctx, settingsType)
if err != nil {
return err
}
previous := ctxUpdate.GetSessionIdentity()
for k, executor := range postHooks {
if err := executor.ExecuteSettingsPostPersistHook(w, r, PostHookPostPersistExecutorParams{
Flow: ctxUpdate.Flow,
Previous: previous,
Updated: i,
Session: ctxUpdate.Session,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookPostPersistExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("A ExecuteSettingsPostPersistHook hook aborted early.")
return nil
}
return e.handleSettingsError(ctx, w, r, settingsType, ctxUpdate.Flow, i, err)
}
e.d.Logger().WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookPostPersistExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("ExecuteSettingsPostPersistHook completed successfully.")
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("Completed all PostSettingsPrePersistHooks and PostSettingsPostPersistHooks.")
trace.SpanFromContext(ctx).AddEvent(events.NewSettingsSucceeded(
ctx, ctxUpdate.Flow.ID, i.ID, string(ctxUpdate.Flow.Type), settingsType))
if ctxUpdate.Flow.Type == flow.TypeAPI {
updatedFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ctxUpdate.Flow.ID)
if err != nil {
return err
}
// ContinueWith items are transient items, not stored in the database, and need to be carried over here, so
// they can be returned to the client.
updatedFlow.ContinueWithItems = ctxUpdate.Flow.ContinueWithItems
e.d.Writer().Write(w, r, updatedFlow)
return nil
}
if err := e.d.SessionManager().IssueCookie(ctx, w, r, ctxUpdate.Session); err != nil {
return errors.WithStack(err)
}
if x.IsJSONRequest(r) {
updatedFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ctxUpdate.Flow.ID)
if err != nil {
return err
}
// ContinueWith items are transient items, not stored in the database, and need to be carried over here, so
// they can be returned to the client.
ctxUpdate.Flow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String()))
updatedFlow.ContinueWithItems = ctxUpdate.Flow.ContinueWithItems
e.d.Writer().Write(w, r, updatedFlow)
return nil
}
redir.ContentNegotiationRedirection(w, r, i.CopyWithoutCredentials(), e.d.Writer(), returnTo.String())
return nil
}
// settingsTypePendingTraitsChange is the settings strategy the verify_new_address
// hook is allowed to be configured under. The JSON config schema
// (selfServiceAfterSettingsProfileMethod) only admits verify_new_address inside
// the profile hook list, so a pending traits change can only ever originate
// from the profile strategy.
const settingsTypePendingTraitsChange = "profile"
// ErrPendingTraitsChangeSessionInvalid is returned by ApplyPendingTraitsChange
// when the session that created the pending change is missing, revoked,
// expired, disabled, or belongs to a different tenant. Callers should treat
// this as "the verification attempt is invalid" — the change is not applied.
var ErrPendingTraitsChangeSessionInvalid = stderrors.New("pending traits change session is no longer valid")
// ApplyPendingTraitsChange commits a pending traits change to the identity and
// fires the "after profile settings, post-persist" hook chain. It is the
// single entry point verification strategies use to resolve a pending change
// created by a settings flow.
//
// The originating session is validated inside the same transaction that
// applies the change, so a session that is revoked between the caller's
// token/code consumption and this call is still rejected. If validation
// succeeds, the loaded session is reused for the hook chain — no second
// GetSession round-trip.
//
// Returns ErrPendingTraitsChangeSessionInvalid when the session is not usable.
// Returns identity.ErrConcurrentModification when the identity's traits have
// drifted since the change was proposed.
func (e *HookExecutor) ApplyPendingTraitsChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
ptc *identity.PendingTraitsChange,
) (i *identity.Identity, err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.ApplyPendingTraitsChange")
defer otelx.End(span, &err)
// Reject PTCs whose FK references were nulled or never written. The
// session FK is ON DELETE SET NULL and the origin-flow FK is ON
// DELETE CASCADE, so a NULL here means either a revoked session or
// a writer that failed to set the link.
if !ptc.SessionID.Valid || !ptc.OriginSettingsFlowID.Valid {
return nil, errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
var (
sess *session.Session
// Capture the pre-change identity before sess.Identity is overwritten
// below so post-persist hooks (notify_previous_addresses) can compute
// diffs without re-reading the row.
previous *identity.Identity
)
err = e.d.TransactionalPersisterProvider().Transaction(ctx, func(ctx context.Context, _ *pop.Connection) error {
// ExpandDefault loads the session's identity in the same query,
// so IsActive() below checks the identity's active state and we
// avoid a second round-trip to IdentityPool.GetIdentity for the
// hash comparison.
s, sErr := e.d.SessionPersister().GetSession(ctx, ptc.SessionID.UUID, session.ExpandDefault)
if sErr != nil {
if errors.Is(sErr, sqlcon.ErrNoRows()) {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
return sErr
}
if s.NID != ptc.NID || !s.IsActive() {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
// TODO(jonas): We can likely drop the identity id column entirely, since the session's identity is always loaded and we check the ID matches here. This would simplify the schema and remove a potential source of FK inconsistency.
if s.Identity == nil || s.Identity.ID != ptc.IdentityID {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
previous = s.Identity
sess = s
if identity.HashTraits(json.RawMessage(s.Identity.Traits)) != ptc.OriginalTraitsHash {
return identity.ErrConcurrentModification
}
if err := e.d.IdentityManager().UpdateTraits(
ctx, ptc.IdentityID, identity.Traits(ptc.ProposedTraits),
identity.ManagerAllowWriteProtectedTraits,
); err != nil {
return err
}
// The PTC Status column is informational only; the real
// single-submit guard is the verification flow state machine
// (StatePassedChallenge) plus atomic token/code consumption.
// We intentionally do not write Status here.
// Re-read the identity after UpdateTraits so the returned
// snapshot reflects the freshly-committed state, including any
// VerifiableAddress row UpdateTraits may have created for the
// new address.
updated, err := e.d.IdentityPool().GetIdentity(ctx, ptc.IdentityID, identity.ExpandDefault)
if err != nil {
return err
}
for idx := range updated.VerifiableAddresses {
a := &updated.VerifiableAddresses[idx]
if a.Value == ptc.NewAddressValue && a.Via == ptc.NewAddressVia {
a.Verified = true
verifiedAt := sqlxx.NullTime(time.Now().UTC())
a.VerifiedAt = &verifiedAt
a.Status = identity.VerifiableAddressStatusCompleted
if err := e.d.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, a, "verified", "verified_at", "status"); err != nil {
return err
}
break
}
}
i = updated
// Keep Session.Identity in sync so post-persist webhook templates render the committed traits and the newly-verified address.
// TODO: pointer aliasing could cause issues if a downstream hook implementation modifies the identity in-place instead of treating it as immutable. We should either enforce immutability or document this behavior.
sess.Identity = updated
return nil
})
if err != nil {
return nil, err
}
// Fire the "profile" post-persist hooks. Load the originating settings flow
// so the hook chain receives a real Flow (honest ID, type, RequestURL)
// instead of a synthetic one. The FK is ON DELETE CASCADE, so if the flow
// was cleaned up the PTC was already deleted and we would not reach this
// call. Apply has already committed, so a hook error cannot roll the change
// back — we propagate errors so callers can surface them in the
// verification UI.
originFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ptc.OriginSettingsFlowID.UUID)
if err != nil {
return nil, err
}
postHooks, err := e.d.PostSettingsPostPersistHooks(ctx, settingsTypePendingTraitsChange)
if err != nil {
return nil, err
}
for k, executor := range postHooks {
logFields := logrus.Fields{
"executor": fmt.Sprintf("%T", executor),
"executor_position": k,
"executors": PostHookPostPersistExecutorNames(postHooks),
"identity_id": i.ID,
"flow_method": settingsTypePendingTraitsChange,
}
if err := executor.ExecuteSettingsPostPersistHook(w, r, PostHookPostPersistExecutorParams{
Flow: originFlow,
Previous: previous,
Updated: i,
Session: sess,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("A post-persist hook aborted the pending-traits-change continuation flow.")
return i, nil
}
return nil, err
}
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("ExecuteSettingsPostPersistHook completed successfully after pending traits change.")
}
return i, nil
}
func (e *HookExecutor) PreSettingsHook(ctx context.Context, w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) (err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.PreSettingsHook")
defer otelx.End(span, &err)
hooks, err := e.d.PreSettingsHooks(ctx)
if err != nil {
return err
}
for _, executor := range hooks {
if err := executor.ExecuteSettingsPreHook(w, r, PreHookExecutorParams{
Flow: a,
Session: s,
}); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,289 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
)
// applyPTCFixture holds a wired-up registry plus a minimal set of persisted
// objects (identity, session, settings flow, verification flow, PTC) for
// unit-testing HookExecutor.ApplyPendingTraitsChange. Subtests mutate the
// PTC before calling ApplyPendingTraitsChange to exercise specific guard paths.
type applyPTCFixture struct {
reg *driver.RegistryDefault
identity *identity.Identity
sess *session.Session
flow *settings.Flow
ptc *identity.PendingTraitsChange
}
// newApplyPTCFixture constructs a fresh, isolated fixture for each subtest.
// All objects are persisted to the SQLite test database.
func newApplyPTCFixture(t *testing.T) *applyPTCFixture {
t.Helper()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
// Create an identity whose traits will be the baseline for the PTC.
i := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"original@example.com"}`),
SchemaID: "default",
State: identity.StateActive,
}
require.NoError(t, reg.IdentityManager().Create(ctx, i, identity.ManagerAllowWriteProtectedTraits))
// Create an active session linked to the identity.
sess, err := testhelpers.NewActiveSession(
httptest.NewRequest(http.MethodGet, "/", nil),
reg, i, time.Now().UTC(),
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
// Create a settings flow that acts as the origin for the PTC.
f := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: "http://localhost/settings",
IdentityID: i.ID,
Identity: i,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
// Create a verification flow — the PTC table has a FK on verification_flow_id
// (ON DELETE CASCADE), so we must persist a real verification flow row.
vf, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
httptest.NewRequest(http.MethodGet, "/verification", nil), nil, flow.TypeBrowser)
require.NoError(t, err)
vf.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, vf))
// Build a PTC that is valid in every field; subtests nil out individual
// fields to trigger the specific guard under test.
sessID := sess.ID
originFlowID := f.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: i.ID,
NewAddressValue: "new@example.com",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(i.Traits)),
ProposedTraits: json.RawMessage(`{"email":"new@example.com"}`),
VerificationFlowID: vf.ID,
Status: identity.PendingTraitsChangeStatusPending,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
return &applyPTCFixture{
reg: reg,
identity: i,
sess: sess,
flow: f,
ptc: ptc,
}
}
func TestApplyPendingTraitsChange_NilOriginSettingsFlowID(t *testing.T) {
t.Parallel()
fx := newApplyPTCFixture(t)
// Simulate a PTC whose OriginSettingsFlowID was never set (malformed writer)
// or was cleared before the apply call.
fx.ptc.OriginSettingsFlowID = uuid.NullUUID{}
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
rw := httptest.NewRecorder()
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
require.Error(t, err)
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
}
// Ensure the fixture itself is valid: a fully-populated PTC must not be
// rejected. This guards against regressions in the fixture setup.
func TestApplyPendingTraitsChange_ValidPTC(t *testing.T) {
t.Parallel()
fx := newApplyPTCFixture(t)
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
rw := httptest.NewRecorder()
updated, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
require.NoError(t, err)
require.NotNil(t, updated)
}
func TestApplyPendingTraitsChange_NilSessionID(t *testing.T) {
t.Parallel()
fx := newApplyPTCFixture(t)
fx.ptc.SessionID = uuid.NullUUID{}
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
rw := httptest.NewRecorder()
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
require.Error(t, err)
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
}
func TestApplyPendingTraitsChange_NIDMismatch(t *testing.T) {
t.Parallel()
fx := newApplyPTCFixture(t)
// Simulate a cross-tenant PTC: the PTC's NID differs from the session's NID.
fx.ptc.NID = uuid.Must(uuid.NewV4())
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
rw := httptest.NewRecorder()
_, err := fx.reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, fx.ptc)
require.Error(t, err)
assert.ErrorIs(t, err, settings.ErrPendingTraitsChangeSessionInvalid)
}
// TestApplyPendingTraitsChange_WebhookReceivesOriginFlow asserts that the
// post-persist hook chain is invoked with the originating settings flow
// (not a synthetic one), so webhook payloads carry the real flow ID,
// type, and request URL.
func TestApplyPendingTraitsChange_WebhookReceivesOriginFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
// Set up a recording webhook target that captures the raw body.
receivedCh := make(chan []byte, 1)
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
select {
case receivedCh <- body:
default:
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(webhookServer.Close)
// Register a web_hook as a settings-profile post-persist hook.
// The jsonnet body template `function(ctx) ctx` forwards the entire context,
// which includes the flow object with its ID, type, and request_url.
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "profile"), []map[string]any{
{
"hook": "web_hook",
"config": map[string]any{
"url": webhookServer.URL,
"method": "POST",
"body": "base64://" + base64.StdEncoding.EncodeToString([]byte(`function(ctx) ctx`)),
},
},
})
t.Cleanup(func() {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "profile"), []map[string]any{})
})
i := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"webhooktest@example.com"}`),
SchemaID: "default",
State: identity.StateActive,
}
require.NoError(t, reg.IdentityManager().Create(ctx, i, identity.ManagerAllowWriteProtectedTraits))
sess, err := testhelpers.NewActiveSession(
httptest.NewRequest(http.MethodGet, "/", nil),
reg, i, time.Now().UTC(),
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
originFlow := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: "http://localhost/settings?origin=real",
IdentityID: i.ID,
Identity: i,
Type: flow.TypeAPI,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, originFlow))
vf, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
httptest.NewRequest(http.MethodGet, "/verification", nil), nil, flow.TypeBrowser)
require.NoError(t, err)
vf.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, vf))
sessID := sess.ID
originFlowID := originFlow.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: i.ID,
NewAddressValue: "webhooktest-new@example.com",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(i.Traits)),
ProposedTraits: json.RawMessage(`{"email":"webhooktest-new@example.com"}`),
VerificationFlowID: vf.ID,
Status: identity.PendingTraitsChangeStatusPending,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
req := httptest.NewRequest(http.MethodGet, "/verify", nil)
rw := httptest.NewRecorder()
_, err = reg.SettingsHookExecutor().ApplyPendingTraitsChange(req.Context(), rw, req, ptc)
require.NoError(t, err)
var receivedBody []byte
select {
case receivedBody = <-receivedCh:
case <-time.After(5 * time.Second):
t.Fatal("settings post-persist webhook was not invoked after pending traits change applied")
}
// The webhook payload should carry the real origin settings flow's ID and
// request URL — not values from a synthetic flow.
assert.Contains(t, string(receivedBody), originFlow.ID.String(),
"webhook payload should contain the real origin settings flow ID")
assert.Contains(t, string(receivedBody), "http://localhost/settings?origin=real",
"webhook payload should contain the real origin settings flow RequestURL")
}

View File

@@ -0,0 +1,197 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/tidwall/gjson"
"github.com/gobuffalo/httptest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/x"
)
func TestSettingsExecutor(t *testing.T) {
ctx := context.Background()
for _, strategy := range []string{
identity.CredentialsTypePassword.String(),
settings.StrategyProfile,
} {
t.Run("strategy="+strategy, func(t *testing.T) {
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL)
reg.WithHooks(map[string]driver.NewHookFn{
"err": func(c config.SelfServiceHook, _ driver.Registry) interface{} {
return &hook.Error{Config: c.Config}
},
})
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
t.Helper()
router := http.NewServeMux()
handleErr := testhelpers.SelfServiceHookSettingsErrorHandler
router.HandleFunc("GET /settings/pre", func(w http.ResponseWriter, r *http.Request) {
if i == nil {
i = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
sess, _ := testhelpers.NewActiveSession(r, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
f, err := settings.NewFlow(reg, r, sess.Identity, ft)
require.NoError(t, err)
if handleErr(t, w, r, reg.SettingsHookExecutor().PreSettingsHook(r.Context(), w, r, f, sess)) {
_, _ = w.Write([]byte("ok"))
}
})
router.HandleFunc("GET /settings/post", func(w http.ResponseWriter, r *http.Request) {
if i == nil {
i = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
sess, _ := testhelpers.NewActiveSession(r, reg, i, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
a, err := settings.NewFlow(reg, r, sess.Identity, ft)
require.NoError(t, err)
a.RequestURL = x.RequestURL(r).String()
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(r.Context(), a))
_ = handleErr(t, w, r, reg.SettingsHookExecutor().
PostSettingsHook(ctx, w, r, strategy, &settings.UpdateContext{Flow: a, Session: sess}, i))
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
return ts
}
makeRequestPost := testhelpers.SelfServiceMakeSettingsPostHookRequest
viperSetPost := func(strategy string, c []config.SelfServiceHook) {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, strategy), c)
}
uiTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
t.Cleanup(uiTS.Close)
uiURL := uiTS.URL + "/user/settings"
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsURL, uiURL)
t.Run("method=PostSettingsHook", func(t *testing.T) {
t.Run("case=pass without hooks", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), uiURL)
})
t.Run("case=pass without hooks if ajax client", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
ts := newServer(t, nil, flow.TypeBrowser)
res, body := makeRequestPost(t, ts, true, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), ts.URL)
assert.EqualValues(t, gjson.Get(body, "continue_with.0.action").String(), "redirect_browser_to")
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), uiURL)
})
t.Run("case=fail if hooks fail", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteSettingsPrePersistHook": "abort"}`)}})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(t, "", body)
})
t.Run("case=use return_to value", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToServer.URL})
testhelpers.SelfServiceHookSettingsSetDefaultRedirectTo(t, conf, "https://www.ory.com")
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{"return_to": {returnToServer.URL + "/kratos"}})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String())
})
t.Run("case=use nested config value", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
testhelpers.SelfServiceHookSettingsSetDefaultRedirectTo(t, conf, returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String())
})
t.Run("case=use nested config value", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
testhelpers.SelfServiceHookSettingsSetDefaultRedirectTo(t, conf, "https://www.ory.com/not-kratos")
testhelpers.SelfServiceHookSettingsSetDefaultRedirectToStrategy(t, conf, strategy, returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String())
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), uiURL)
})
t.Run("case=send a json response for API clients", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(strategy, nil)
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeAPI), true, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "identity.id"))
})
t.Run("case=pass without hooks for browser flow with application/json", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), true, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "identity.id"))
})
})
for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(kind)+"/method=PreSettingsHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceSettingsBeforeHooks,
testhelpers.SelfServiceMakeSettingsPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, nil, kind)
},
conf,
))
}
})
}
}

View File

@@ -0,0 +1,98 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
)
// TestNotifyPreviousAddresses_Integration exercises the post-persist hook
// against a real registry and courier persister without going through the
// HTTP layer. This catches wiring regressions between the hook, the identity
// manager, the courier, and the template loader that a pure-unit test for
// the hook cannot detect.
func TestNotifyPreviousAddresses_Integration(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
t.Run("case=direct persist queues notification to removed address", func(t *testing.T) {
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = identity.Traits(`{"email":"old@example.com"}`)
original.VerifiableAddresses = []identity.VerifiableAddress{
{Value: "old@example.com", Via: identity.AddressTypeEmail, Verified: true},
}
require.NoError(t, reg.IdentityManager().Create(ctx, original))
proposed := *original
proposed.Traits = identity.Traits(`{"email":"new@example.com"}`)
proposed.VerifiableAddresses = []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, Verified: false},
}
f := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().Add(time.Hour),
IssuedAt: time.Now(),
RequestURL: "http://foo.com/settings",
IdentityID: proposed.ID,
Identity: &proposed,
Type: flow.TypeBrowser,
UI: &container.Container{Method: "POST"},
InternalContext: []byte("{}"),
}
sess := &session.Session{Identity: original}
h := hook.NewNotifyPreviousAddresses(reg, &hook.NotifyPreviousAddressesConfig{
Recipients: hook.RecipientsRemoved,
})
r := httptest.NewRequest("POST", "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.NoError(t, h.ExecuteSettingsPostPersistHook(w, r, settings.PostHookPostPersistExecutorParams{
Flow: f,
Previous: original,
Updated: &proposed,
Session: sess,
}))
messages, err := reg.CourierPersister().NextMessages(ctx, 10)
require.NoError(t, err)
require.Len(t, messages, 1)
assert.Equal(t, "old@example.com", messages[0].Recipient)
assert.Equal(t, courier.MessageTypeEmail, messages[0].Type)
assert.Contains(t, messages[0].Subject, "address")
})
t.Run("case=registry wires hook when configured via SelfServiceHook", func(t *testing.T) {
executor := reg.HookNotifyPreviousAddresses(&hook.NotifyPreviousAddressesConfig{
Recipients: hook.RecipientsAllVerified,
})
require.NotNil(t, executor)
var _ settings.PostHookPostPersistExecutor = executor
})
}

View File

@@ -0,0 +1,113 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"net/http"
"slices"
"strings"
"github.com/gofrs/uuid"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/x"
)
var organizationFilter = []StrategyFilter{func(s Strategy) bool {
a, b := s.(flow.OrganizationImplementor)
return b && a.SupportsOrganizations()
}}
// PrepareOrganizations resolves the org id from the identity (winner) or the
// `?organization=` query param, sets it on the flow, and returns a filter
// that keeps only org-implementing strategies. Returns an empty filter when
// no org id applies — the flow then proceeds as a normal (non-org) settings
// flow.
//
// The identity is the authoritative source: an identity already bound to an
// org cannot be silently re-bound, and the session cache must not be relied
// on because it can lag behind a just-persisted org binding (PostSettingsHook
// rebuilds the flow with the freshly linked identity).
//
// The query param is free-form caller input, so it is only honored when one
// of the identity's email addresses is under the organization's configured
// domains. Without that gate, any authenticated identity could render an
// org-scoped flow for every org in the project and enumerate its SSO
// providers. The org hooks block self-service registration and profile
// updates under a claimed domain, so a matching address implies the domain
// owner provisioned the account.
//
// When the resolved org id does not match any configured organization, the
// flow falls back to the unscoped path. An identity may carry an
// organization_id for an organization that has since been deleted; scoping
// to a dangling org would render no nodes and trap chained flows such as
// recovery -> settings.
func PrepareOrganizations(r *http.Request, f *Flow, i *identity.Identity, configuredOrgs []config.Organization) []StrategyFilter {
if f.OrganizationID.Valid {
return organizationFilter
}
if i != nil && i.OrganizationID.Valid {
if _, ok := findConfiguredOrganization(i.OrganizationID.UUID, configuredOrgs); !ok {
return []StrategyFilter{}
}
f.OrganizationID = i.OrganizationID
return organizationFilter
}
orgID := flow.ParseOrganizationFromURLQuery(r.Context(), r.URL.Query())
if !orgID.Valid {
return []StrategyFilter{}
}
org, ok := findConfiguredOrganization(orgID.UUID, configuredOrgs)
if !ok {
return []StrategyFilter{}
}
if !identityEmailMatchesOrganizationDomains(i, org) {
return []StrategyFilter{}
}
f.OrganizationID = orgID
return organizationFilter
}
func findConfiguredOrganization(id uuid.UUID, configured []config.Organization) (config.Organization, bool) {
idx := slices.IndexFunc(configured, func(o config.Organization) bool {
return o.ID == id
})
if idx < 0 {
return config.Organization{}, false
}
return configured[idx], true
}
// identityEmailMatchesOrganizationDomains reports whether one of the
// identity's email addresses is under one of the organization's domains. The
// normalization mirrors the org email-domain matching in the cloud module's
// organization hook.
func identityEmailMatchesOrganizationDomains(i *identity.Identity, org config.Organization) bool {
if i == nil {
return false
}
// TODO(jonas): This should actually use the `organization.via` field of the identity schema.
for _, addr := range i.VerifiableAddresses {
if addr.Via != identity.AddressTypeEmail {
continue
}
_, domain, ok := strings.Cut(x.NormalizeEmailIdentifier(addr.Value), "@")
if !ok || domain == "" {
continue
}
if slices.ContainsFunc(org.Domains, func(orgDomain string) bool {
return strings.EqualFold(domain, x.NormalizeOtherIdentifier(orgDomain))
}) {
return true
}
}
return false
}

View File

@@ -0,0 +1,157 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"context"
"net/http/httptest"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/settings"
)
func TestPrepareOrganizations(t *testing.T) {
ctx := context.Background()
orgQuery := uuid.Must(uuid.NewV4())
orgIdentity := uuid.Must(uuid.NewV4())
configured := []config.Organization{
{ID: orgQuery, Domains: []string{"corp.example"}},
{ID: orgIdentity, Domains: []string{"other-corp.example"}},
}
// identityWithEmail returns an unbound identity carrying a single email
// verifiable address, mirroring what the session loader hydrates via
// identity.ExpandDefault.
identityWithEmail := func(email string) *identity.Identity {
return &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Value: email, Via: identity.AddressTypeEmail},
},
}
}
t.Run("returns empty filter when no org id is set", func(t *testing.T) {
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser", nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("user@corp.example"), configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("uses query param when the identity's email is under the org's domains", func(t *testing.T) {
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("user@corp.example"), configured)
assert.Len(t, filters, 1)
assert.Equal(t, orgQuery, f.OrganizationID.UUID)
})
t.Run("domain match is case-insensitive", func(t *testing.T) {
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("User@CORP.Example"), configured)
assert.Len(t, filters, 1)
assert.Equal(t, orgQuery, f.OrganizationID.UUID)
})
t.Run("subdomain of a configured domain is not a match", func(t *testing.T) {
// Domain comparison is an exact match, not a suffix match: an email
// under sub.corp.example must not satisfy an org that claims
// corp.example. Suffix matching would let anyone controlling an
// arbitrary subdomain-style address scope flows to the org.
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("user@sub.corp.example"), configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("query param with a non-matching email domain falls back to unscoped", func(t *testing.T) {
// The query parameter is free-form caller input. Without a domain
// match it must not scope the flow — otherwise any authenticated
// identity could enumerate the SSO providers of every org in the
// project.
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("user@elsewhere.example"), configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("query param with an identity without email addresses falls back to unscoped", func(t *testing.T) {
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, &identity.Identity{}, configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("non-email addresses do not count as a domain match", func(t *testing.T) {
f := &settings.Flow{}
i := &identity.Identity{
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "user@corp.example", Via: identity.AddressTypeSMS},
},
}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, i, configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("nil identity cannot be scoped via the query param", func(t *testing.T) {
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, nil, configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("identity org id overrides query param without a domain check", func(t *testing.T) {
// An identity already bound to an org cannot be silently re-bound to
// the org carried by the query string. Membership is proven by the
// binding itself, so no email-domain match is required.
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+orgQuery.String(), nil).WithContext(ctx)
i := &identity.Identity{OrganizationID: uuid.NullUUID{UUID: orgIdentity, Valid: true}}
filters := settings.PrepareOrganizations(r, f, i, configured)
assert.Len(t, filters, 1)
assert.Equal(t, orgIdentity, f.OrganizationID.UUID)
})
t.Run("preserves an already-set flow org id", func(t *testing.T) {
f := &settings.Flow{OrganizationID: uuid.NullUUID{UUID: orgQuery, Valid: true}}
r := httptest.NewRequest("GET", "/self-service/settings/browser", nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, &identity.Identity{}, configured)
assert.Len(t, filters, 1)
assert.Equal(t, orgQuery, f.OrganizationID.UUID)
})
t.Run("identity org id pointing at a deleted org falls back to unscoped", func(t *testing.T) {
// An identity bound to an organization that has since been deleted
// must not trap the user in an org-scoped flow with no nodes —
// notably the recovery -> settings hand-off relies on the password
// node being rendered for offboarded users.
f := &settings.Flow{}
r := httptest.NewRequest("GET", "/self-service/settings/browser", nil).WithContext(ctx)
i := &identity.Identity{OrganizationID: uuid.NullUUID{UUID: uuid.Must(uuid.NewV4()), Valid: true}}
filters := settings.PrepareOrganizations(r, f, i, configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
t.Run("query param org id that is not configured falls back to unscoped", func(t *testing.T) {
f := &settings.Flow{}
unknown := uuid.Must(uuid.NewV4())
r := httptest.NewRequest("GET", "/self-service/settings/browser?organization="+unknown.String(), nil).WithContext(ctx)
filters := settings.PrepareOrganizations(r, f, identityWithEmail("user@corp.example"), configured)
assert.Empty(t, filters)
assert.False(t, f.OrganizationID.Valid)
})
}

View File

@@ -0,0 +1,23 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type (
FlowPersister interface {
CreateSettingsFlow(context.Context, *Flow) error
GetSettingsFlow(ctx context.Context, id uuid.UUID) (*Flow, error)
UpdateSettingsFlow(context.Context, *Flow) error
DeleteExpiredSettingsFlows(context.Context, time.Time, int) error
}
FlowPersistenceProvider interface {
SettingsFlowPersister() FlowPersister
}
)

View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"github.com/ory/kratos/ui/node"
)
func sortNodes(ctx context.Context, n node.Nodes, schemaRef string) error {
return n.SortBySchema(
ctx,
node.SortBySchema(schemaRef),
node.SortByGroups([]node.UiNodeGroup{
node.DefaultGroup,
node.ProfileGroup,
node.PasswordGroup,
node.OpenIDConnectGroup,
node.LookupGroup,
node.WebAuthnGroup,
node.PasskeyGroup,
node.TOTPGroup,
}),
node.SortStableGroups(node.DeviceAuthnGroup),
node.SortUseOrderAppend([]string{
// Lookup
node.LookupReveal,
node.LookupRegenerate,
node.LookupCodes,
node.LookupConfirm,
// WebAuthn
node.WebAuthnRemove,
node.WebAuthnRegisterDisplayName,
node.WebAuthnRegister,
// Passkey
node.PasskeyRemove,
node.PasskeyRegister,
// TOTP
node.TOTPQR,
node.TOTPSecretKey,
node.TOTPUnlink,
node.TOTPCode,
}),
)
}

View File

@@ -0,0 +1,16 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import "github.com/ory/kratos/selfservice/flow"
// State represents the state of this flow. It knows two states:
//
// - show_form: No user data has been collected, or it is invalid, and thus the form should be shown.
// - success: Indicates that the settings flow has been updated successfully with the provided data.
// Done will stay true when repeatedly checking. If set to true, done will revert back to false only
// when a flow with invalid (e.g. "please use a valid phone number") data was sent.
//
// swagger:model settingsFlowState
type State = flow.State

View File

@@ -0,0 +1,61 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"net/http"
"reflect"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/pkg/errors"
"github.com/ory/kratos/identity"
)
const (
StrategyProfile = "profile"
)
var pkgName = reflect.TypeOf(Strategies{}).PkgPath()
type Strategy interface {
SettingsStrategyID() string
NodeGroup() node.UiNodeGroup
PopulateSettingsMethod(context.Context, *http.Request, *identity.Identity, *Flow) error
Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *Flow, s *session.Session) (*UpdateContext, error)
}
type Strategies []Strategy
func (s Strategies) Strategy(id string) (Strategy, error) {
ids := make([]string, len(s))
for k, ss := range s {
ids[k] = ss.SettingsStrategyID()
if ss.SettingsStrategyID() == id {
return ss, nil
}
}
return nil, errors.Errorf(`unable to find strategy for %s have %v`, id, ids)
}
func (s Strategies) MustStrategy(id string) Strategy {
strategy, err := s.Strategy(id)
if err != nil {
panic(err)
}
return strategy
}
// StrategyFilter selects which Settings strategies to include when populating a flow.
type StrategyFilter func(strategy Strategy) bool
type StrategyProvider interface {
SettingsStrategies(ctx context.Context, filters ...StrategyFilter) Strategies
AllSettingsStrategies() Strategies
}

View File

@@ -0,0 +1,131 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"net/http"
"runtime/debug"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/herodot"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
var (
ErrContinuePreviousAction = errors.New("found prior action")
)
type UpdatePayload interface {
GetFlowID() uuid.UUID
SetFlowID(id uuid.UUID)
}
type UpdateContext struct {
Session *session.Session
Flow *Flow
toUpdate *identity.Identity
// excludeCredentialTypesFromUpdate lists credential types the
// post-settings identity persist must not touch; strategies that persist
// those credentials themselves (e.g. through a locked credential-config
// update) set this via ExcludeCredentialTypesFromUpdate.
excludeCredentialTypesFromUpdate []identity.CredentialsType
}
func (c *UpdateContext) UpdateIdentity(i *identity.Identity) {
c.toUpdate = i
}
// ExcludeCredentialTypesFromUpdate marks the given credential types as
// self-persisted: the post-settings identity persist leaves their rows
// untouched.
func (c *UpdateContext) ExcludeCredentialTypesFromUpdate(cts ...identity.CredentialsType) {
c.excludeCredentialTypesFromUpdate = append(c.excludeCredentialTypesFromUpdate, cts...)
}
func (c *UpdateContext) GetIdentityToUpdate() (*identity.Identity, error) {
if c.toUpdate == nil {
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Could not find a identity to update."))
}
return c.toUpdate, nil
}
func (c UpdateContext) GetSessionIdentity() *identity.Identity {
if c.Session == nil {
return nil
}
return c.Session.Identity
}
func PrepareUpdate(d interface {
logrusx.Provider
continuity.ManagementProvider
x.CookieProvider
}, w http.ResponseWriter, r *http.Request, f *Flow, ss *session.Session, name string, payload UpdatePayload) (*UpdateContext, error) {
payload.SetFlowID(f.ID)
c := &UpdateContext{Session: ss, Flow: f}
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
return c, nil
}
cookieStore := continuity.NewCookieReferenceStore(d.ContinuityCookieManager(r.Context()))
if _, err := d.ContinuityManager().Continue(r.Context(), w, r, name, cookieStore, ContinuityOptions(payload, ss.Identity)...); err == nil {
if payload.GetFlowID() == f.ID {
return c, ErrContinuePreviousAction
}
d.Logger().
WithField("package", pkgName).
WithField("stack_trace", string(debug.Stack())).
WithField("expected_request_id", payload.GetFlowID()).
WithField("actual_request_id", f.ID).
Debug("Flow ID from continuity manager does not match Flow ID from request.")
return c, nil
} else if !errors.Is(err, continuity.ErrNotResumable()) {
return new(UpdateContext), err
}
return c, nil
}
func ContinuityOptions(p interface{}, i *identity.Identity) []continuity.ManagerOption {
return []continuity.ManagerOption{
continuity.WithPayload(p),
continuity.WithIdentity(i),
continuity.WithLifespan(time.Minute * 15),
}
}
func GetFlowID(r *http.Request) (uuid.UUID, error) {
rid := x.ParseUUID(r.URL.Query().Get("flow"))
if rid == uuid.Nil {
return rid, errors.WithStack(herodot.ErrBadRequest().WithReasonf("The flow query parameter is missing or malformed."))
}
return rid, nil
}
func OnUnauthenticated(reg interface {
config.Provider
httpx.WriterProvider
}) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
handler := session.RedirectOnUnauthenticated(reg.Config().SelfServiceFlowLoginUI(r.Context()).String())
if x.IsJSONRequest(r) {
handler = session.RespondWithJSONErrorOnAuthenticated(reg.Writer(), herodot.ErrUnauthorized().WithReasonf("A valid Ory Session Cookie or Ory Session Token is missing."))
}
handler(w, r)
}
}

View File

@@ -0,0 +1,26 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/x"
)
func TestGetIdentityToUpdate(t *testing.T) {
c := new(UpdateContext)
_, err := c.GetIdentityToUpdate()
require.Error(t, err)
expected := &identity.Identity{ID: x.NewUUID()}
c.UpdateIdentity(expected)
actual, err := c.GetIdentityToUpdate()
require.NoError(t, err)
require.Equal(t, expected, actual)
}

View File

@@ -0,0 +1,48 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits":{
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
}
}
},
"name": {
"type": "string",
"format": "email",
"minLength": 10
},
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"booly": {
"type": "boolean"
},
"should_big_number": {
"type": "number",
"minimum": 1200
},
"should_long_string": {
"type": "string",
"minLength": 25
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"emails": {
"type": "array",
"items": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,260 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/persistence"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/x/sqlcon"
"github.com/ory/x/assertx"
"github.com/ory/kratos/ui/node"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/x"
)
func clearids(r *settings.Flow) {
r.ID = uuid.Nil
r.Identity.ID = uuid.Nil
r.IdentityID = uuid.Nil
}
func TestFlowPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
return func(t *testing.T) {
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=should error when the settings request does not exist", func(t *testing.T) {
_, err := p.GetSettingsFlow(ctx, x.NewUUID())
require.Error(t, err)
})
newFlow := func(t *testing.T) *settings.Flow {
var r settings.Flow
require.NoError(t, faker.FakeData(&r))
clearids(&r)
require.NoError(t, p.CreateIdentity(ctx, r.Identity))
require.NotZero(t, r.Identity.ID)
r.IdentityID = r.Identity.ID
r.State = flow.StateShowForm
return &r
}
t.Run("case=should create a new settings request", func(t *testing.T) {
r := newFlow(t)
err := p.CreateSettingsFlow(ctx, r)
require.NoError(t, err, "%#v", err)
t.Run("can not fetch on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetSettingsFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create and update and properly deal with internal context", func(t *testing.T) {
for k, tc := range []struct {
in []byte
expect string
}{
{in: []byte("[]"), expect: "{}"},
{expect: "{}"},
{in: []byte("null"), expect: "{}"},
{in: []byte(`{"foo":"bar"}`), expect: `{"foo":"bar"}`},
} {
t.Run(fmt.Sprintf("run=%d", k), func(t *testing.T) {
r := newFlow(t)
r.InternalContext = tc.in
require.NoError(t, p.CreateSettingsFlow(ctx, r))
assert.Equal(t, tc.expect, string(r.InternalContext))
r.InternalContext = tc.in
require.NoError(t, p.UpdateSettingsFlow(ctx, r))
assert.Equal(t, tc.expect, string(r.InternalContext))
})
}
})
t.Run("case=should ensure that internal context is an object", func(t *testing.T) {
r := newFlow(t)
r.InternalContext = []byte("null")
require.NoError(t, p.CreateSettingsFlow(ctx, r))
assert.Equal(t, "{}", string(r.InternalContext))
r.InternalContext = nil
require.NoError(t, p.UpdateSettingsFlow(ctx, r))
assert.Equal(t, "{}", string(r.InternalContext))
})
t.Run("case=should create with set ids", func(t *testing.T) {
var r settings.Flow
require.NoError(t, faker.FakeData(&r))
r.State = flow.StateShowForm
require.NoError(t, p.CreateIdentity(ctx, r.Identity))
require.NoError(t, p.CreateSettingsFlow(ctx, &r))
require.NotEmpty(t, r.Identity)
t.Run("can not fetch on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetSettingsFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create and fetch a settings request", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateSettingsFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetSettingsFlow(ctx, expected.ID)
require.NoError(t, err)
factual, _ := json.Marshal(actual.UI)
fexpected, _ := json.Marshal(expected.UI)
require.NotEmpty(t, actual.UI.Action)
assert.EqualValues(t, expected.ID, actual.ID)
assert.JSONEq(t, string(fexpected), string(factual))
x.AssertEqualTime(t, expected.IssuedAt, actual.IssuedAt)
x.AssertEqualTime(t, expected.ExpiresAt, actual.ExpiresAt)
assert.EqualValues(t, expected.RequestURL, actual.RequestURL)
assert.EqualValues(t, expected.Identity.ID, actual.Identity.ID)
assert.EqualValues(t, expected.Identity.Traits, actual.Identity.Traits)
assert.EqualValues(t, expected.Identity.SchemaID, actual.Identity.SchemaID)
assert.Empty(t, actual.Identity.Credentials)
})
t.Run("case=should fail to create if identity does not exist", func(t *testing.T) {
var expected settings.Flow
require.NoError(t, faker.FakeData(&expected))
clearids(&expected)
expected.Identity = nil
expected.IdentityID = uuid.Nil
expected.State = flow.StateShowForm
err := p.CreateSettingsFlow(ctx, &expected)
require.Errorf(t, err, "%+v", expected)
})
t.Run("case=should create and update a settings request", func(t *testing.T) {
expected := newFlow(t)
expected.UI.Nodes = node.Nodes{}
expected.UI.Nodes.Append(node.NewInputField("zab", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})))
expected.UI.Nodes.Append(node.NewInputField("foo", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})))
err := p.CreateSettingsFlow(ctx, expected)
require.NoError(t, err)
expected.UI.Action = "/new-action"
expected.UI.Nodes.Append(
node.NewInputField("zab", nil, node.DefaultGroup, "zab", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "zab"
})))
expected.RequestURL = "/new-request-url"
require.NoError(t, p.UpdateSettingsFlow(ctx, expected))
actual, err := p.GetSettingsFlow(ctx, expected.ID)
require.NoError(t, err)
assert.Equal(t, "/new-action", actual.UI.Action)
assert.Equal(t, "/new-request-url", actual.RequestURL)
assertx.EqualAsJSON(t, node.Nodes{
// v0.5: {Name: "zab", Type: "zab", Pattern: "zab"},
node.NewInputField("zab", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})),
node.NewInputField("foo", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})),
// v0.5: {Name: "zab", Type: "bar", Pattern: "baz"},
node.NewInputField("zab", nil, node.DefaultGroup, "zab", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "zab"
})),
}, actual.UI.Nodes)
})
t.Run("case=network", func(t *testing.T) {
id := x.NewUUID()
iid := x.NewUUID()
nid, p := testhelpers.NewNetwork(t, ctx, p)
require.NoError(t, p.CreateIdentity(ctx, &identity.Identity{ID: iid}))
t.Run("sets id on creation", func(t *testing.T) {
expected := &settings.Flow{ID: id, IdentityID: iid, State: flow.StateShowForm, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}
require.NoError(t, p.CreateSettingsFlow(ctx, expected))
assert.EqualValues(t, id, expected.ID)
assert.EqualValues(t, nid, expected.NID)
actual, err := p.GetSettingsFlow(ctx, id)
require.NoError(t, err)
assert.EqualValues(t, id, actual.ID)
assert.EqualValues(t, nid, actual.NID)
_, err = p.GetSettingsFlow(ctx, id)
require.NoError(t, err)
})
t.Run("can not update id", func(t *testing.T) {
expected, err := p.GetSettingsFlow(ctx, id)
require.NoError(t, err)
_, other := testhelpers.NewNetwork(t, ctx, p)
expected.RequestURL = "updated"
require.ErrorIs(t, other.UpdateSettingsFlow(ctx, expected), sqlcon.ErrNoRows())
actual, err := p.GetSettingsFlow(ctx, id)
require.NoError(t, err)
assert.EqualValues(t, id, actual.ID)
assert.EqualValues(t, nid, actual.NID)
require.NotEqual(t, "updated", actual.RequestURL)
})
t.Run("can not get on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetSettingsFlow(ctx, id)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
t.Run("network isolation", func(t *testing.T) {
nid2, _ := testhelpers.NewNetwork(t, ctx, p)
nid1, p := testhelpers.NewNetwork(t, ctx, p)
iid1, iid2 := x.NewUUID(), x.NewUUID()
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid1, nid1, time.Now(), time.Now()).Exec())
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid2, nid2, time.Now(), time.Now()).Exec())
sid1, sid2 := x.NewUUID(), x.NewUUID()
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO selfservice_settings_flows (id, nid, identity_id, ui, created_at, updated_at, expires_at, request_url, internal_context) VALUES (?, ?, ?, '{}', ?, ?, ?, '', '{}')", sid1, nid1, iid1, time.Now(), time.Now(), time.Now().Add(time.Hour)).Exec())
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO selfservice_settings_flows (id, nid, identity_id, ui, created_at, updated_at, expires_at, request_url, internal_context) VALUES (?, ?, ?, '{}', ?, ?, ?, '', '{}')", sid2, nid2, iid2, time.Now(), time.Now(), time.Now().Add(time.Hour)).Exec())
_, err := p.GetSettingsFlow(ctx, sid1)
require.NoError(t, err)
_, err = p.GetSettingsFlow(ctx, sid2)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
}
}

View File

@@ -0,0 +1,20 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings_test
import (
"net/http"
"net/http/httptest"
"os"
"testing"
)
var returnToServer *httptest.Server
func TestMain(m *testing.M) {
returnToServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
}))
os.Exit(m.Run())
}