snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
287
selfservice/flow/settings/error.go
Normal file
287
selfservice/flow/settings/error.go
Normal 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)
|
||||
}
|
||||
}
|
||||
420
selfservice/flow/settings/error_test.go
Normal file
420
selfservice/flow/settings/error_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
261
selfservice/flow/settings/flow.go
Normal file
261
selfservice/flow/settings/flow.go
Normal 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,
|
||||
}
|
||||
}
|
||||
187
selfservice/flow/settings/flow_test.go
Normal file
187
selfservice/flow/settings/flow_test.go
Normal 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)
|
||||
}
|
||||
708
selfservice/flow/settings/handler.go
Normal file
708
selfservice/flow/settings/handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
33
selfservice/flow/settings/handler_change_password_test.go
Normal file
33
selfservice/flow/settings/handler_change_password_test.go
Normal 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"))
|
||||
}
|
||||
750
selfservice/flow/settings/handler_test.go
Normal file
750
selfservice/flow/settings/handler_test.go
Normal 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"),
|
||||
)
|
||||
})
|
||||
}
|
||||
558
selfservice/flow/settings/hook.go
Normal file
558
selfservice/flow/settings/hook.go
Normal 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
|
||||
}
|
||||
289
selfservice/flow/settings/hook_pending_traits_change_test.go
Normal file
289
selfservice/flow/settings/hook_pending_traits_change_test.go
Normal 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")
|
||||
}
|
||||
197
selfservice/flow/settings/hook_test.go
Normal file
197
selfservice/flow/settings/hook_test.go
Normal 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,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
113
selfservice/flow/settings/organizations.go
Normal file
113
selfservice/flow/settings/organizations.go
Normal 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
|
||||
}
|
||||
157
selfservice/flow/settings/organizations_test.go
Normal file
157
selfservice/flow/settings/organizations_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
23
selfservice/flow/settings/persistence.go
Normal file
23
selfservice/flow/settings/persistence.go
Normal 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
|
||||
}
|
||||
)
|
||||
50
selfservice/flow/settings/sort.go
Normal file
50
selfservice/flow/settings/sort.go
Normal 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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
16
selfservice/flow/settings/state.go
Normal file
16
selfservice/flow/settings/state.go
Normal 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
|
||||
61
selfservice/flow/settings/strategy.go
Normal file
61
selfservice/flow/settings/strategy.go
Normal 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
|
||||
}
|
||||
131
selfservice/flow/settings/strategy_helper.go
Normal file
131
selfservice/flow/settings/strategy_helper.go
Normal 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)
|
||||
}
|
||||
}
|
||||
26
selfservice/flow/settings/strategy_helper_test.go
Normal file
26
selfservice/flow/settings/strategy_helper_test.go
Normal 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)
|
||||
}
|
||||
48
selfservice/flow/settings/stub/identity.schema.json
Normal file
48
selfservice/flow/settings/stub/identity.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
selfservice/flow/settings/stub/multi-email.schema.json
Normal file
29
selfservice/flow/settings/stub/multi-email.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
260
selfservice/flow/settings/test/persistence.go
Normal file
260
selfservice/flow/settings/test/persistence.go
Normal 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())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
20
selfservice/flow/settings/testsetup_test.go
Normal file
20
selfservice/flow/settings/testsetup_test.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user