snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

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

View File

@@ -0,0 +1,39 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/gofrs/uuid"
)
// swagger:model flowError
type ErrorContainer struct {
// ID of the error container.
//
// required: true
ID uuid.UUID `db:"id" json:"id"`
NID uuid.UUID `json:"-" db:"nid"`
// The error
Errors json.RawMessage `json:"error" db:"errors"`
// CreatedAt is a helper struct field for gobuffalo.pop.
CreatedAt time.Time `json:"created_at" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
SeenAt sql.NullTime `json:"-" db:"seen_at"`
WasSeen bool `json:"-" db:"was_seen"`
CSRFToken string `db:"csrf_token" json:"-"`
}
func (e ErrorContainer) TableName(ctx context.Context) string {
return "selfservice_errors"
}

View File

@@ -0,0 +1,117 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx
import (
"cmp"
"encoding/json"
"net/http"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/nosurf"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
)
const RouteGet = "/self-service/errors"
var stub500, _ = json.Marshal(herodot.ErrInternalServerError().WithReasonf("This is a stub error."))
type (
handlerDependencies interface {
httpx.WriterProvider
PersistenceProvider
config.Provider
}
HandlerProvider interface {
SelfServiceErrorHandler() *Handler
}
Handler struct {
r handlerDependencies
csrf nosurfx.CSRFToken
}
)
func NewHandler(
r handlerDependencies,
) *Handler {
return &Handler{r: r, csrf: nosurf.Token}
}
func (h *Handler) WithTokenGenerator(f func(r *http.Request) string) {
h.csrf = f
}
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
public.GET(RouteGet, h.publicFetchError)
}
func (h *Handler) RegisterAdminRoutes(public *httprouterx.RouterAdmin) {
public.GET(RouteGet, redir.RedirectToPublicRoute(h.r))
}
// swagger:parameters getFlowError
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getFlowError struct {
// Error is the error's ID
//
// in: query
// required: true
ID string `json:"id"`
}
// swagger:route GET /self-service/errors frontend getFlowError
//
// # Get User-Flow Errors
//
// This endpoint returns the error associated with a user-facing self service errors.
//
// This endpoint supports stub values to help you implement the error UI:
//
// - `?id=stub:500` - returns a stub 500 (Internal Server Error) error.
//
// More information can be found at [Ory Kratos User User Facing Error Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-facing-errors).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: flowError
// 403: errorGeneric
// 404: errorGeneric
// 500: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) publicFetchError(w http.ResponseWriter, r *http.Request) {
if err := h.fetchError(w, r); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
}
func (h *Handler) fetchError(w http.ResponseWriter, r *http.Request) error {
id := cmp.Or(r.URL.Query().Get("error"), r.URL.Query().Get("id"))
switch id {
case "stub:500":
h.r.Writer().Write(w, r, &ErrorContainer{ID: x.NewUUID(), Errors: stub500})
return nil
}
es, err := h.r.SelfServiceErrorPersister().ReadErrorContainer(r.Context(), x.ParseUUID(id))
if err != nil {
return err
}
h.r.Writer().Write(w, r, es)
return nil
}

View File

@@ -0,0 +1,133 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/nosurf"
"github.com/ory/x/assertx"
"github.com/ory/x/errorsx"
"github.com/ory/x/httprouterx"
)
func TestHandler(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
h := errorx.NewHandler(reg)
t.Run("case=public authorization", func(t *testing.T) {
router := httprouterx.NewRouterPublic()
ns := nosurfx.NewTestCSRFHandler(router, reg)
h.RegisterPublicRoutes(router)
router.GET("/regen", func(w http.ResponseWriter, r *http.Request) {
ns.RegenerateToken(w, r)
w.WriteHeader(http.StatusNoContent)
})
router.GET("/set-error", func(w http.ResponseWriter, r *http.Request) {
id, err := reg.SelfServiceErrorPersister().CreateErrorContainer(context.Background(), nosurf.Token(r), herodot.ErrNotFound().WithReason("foobar"))
require.NoError(t, err)
_, _ = w.Write([]byte(id.String()))
})
ts := httptest.NewServer(ns)
defer ts.Close()
getBody := func(t *testing.T, hc *http.Client, path string, expectedCode int) []byte {
res, err := hc.Get(ts.URL + path)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.EqualValues(t, expectedCode, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return body
}
expectedError := x.MustEncodeJSON(t, herodot.ErrNotFound().WithReason("foobar"))
t.Run("call with valid csrf cookie", func(t *testing.T) {
hc := testhelpers.NewTestClient(t)
id := getBody(t, hc, "/set-error", http.StatusOK)
actual := getBody(t, hc, errorx.RouteGet+"?id="+string(id), http.StatusOK)
assert.JSONEq(t, expectedError, gjson.GetBytes(actual, "error").Raw, "%s", actual)
// We expect a forbid error if the error is not found, regardless of CSRF
_ = getBody(t, hc, errorx.RouteGet+"?id=does-not-exist", http.StatusNotFound)
})
})
t.Run("case=stubs", func(t *testing.T) {
router := httprouterx.NewRouterPublic()
h.RegisterPublicRoutes(router)
ts := httptest.NewServer(router)
defer ts.Close()
res, err := ts.Client().Get(ts.URL + errorx.RouteGet + "?id=stub:500")
require.NoError(t, err)
require.EqualValues(t, http.StatusOK, res.StatusCode)
actual, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.EqualValues(t, "This is a stub error.", gjson.GetBytes(actual, "error.reason").String())
})
t.Run("case=errors types", func(t *testing.T) {
router := httprouterx.NewRouterPublic()
h.RegisterPublicRoutes(router)
ts := httptest.NewServer(router)
defer ts.Close()
for k, tc := range []struct {
gave error
}{
{gave: herodot.ErrNotFound().WithReason("foobar")},
{gave: herodot.ErrNotFound().WithReason("foobar")},
{gave: errors.WithStack(herodot.ErrNotFound().WithReason("foobar"))},
{gave: errors.WithStack(herodot.ErrNotFound().WithReason("foobar").WithTrace(errors.New("asdf")))},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
csrf := x.NewUUID()
id, err := reg.SelfServiceErrorPersister().CreateErrorContainer(context.Background(), csrf.String(), tc.gave)
require.NoError(t, err)
res, err := ts.Client().Get(ts.URL + errorx.RouteGet + "?id=" + id.String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.EqualValues(t, http.StatusOK, res.StatusCode)
actual, err := io.ReadAll(res.Body)
require.NoError(t, err)
gg := errorsx.Cause(tc.gave)
expected, err := json.Marshal(errorx.ErrorContainer{
ID: id,
Errors: x.RequireJSONMarshal(t, gg),
})
require.NoError(t, err)
assertx.EqualAsJSONExcept(t, json.RawMessage(expected), json.RawMessage(actual), []string{"created_at", "updated_at"})
assert.Empty(t, gjson.GetBytes(actual, "csrf_token").String())
assert.JSONEq(t, string(x.RequireJSONMarshal(t, gg)), gjson.GetBytes(actual, "error").Raw)
t.Logf("%s", actual)
})
}
})
}

View File

@@ -0,0 +1,82 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx
import (
"context"
"net/http"
"net/url"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/urlx"
"github.com/ory/kratos/x"
)
type (
managerDependencies interface {
PersistenceProvider
logrusx.Provider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
config.Provider
}
Manager struct {
d managerDependencies
}
ManagementProvider interface {
// SelfServiceErrorManager returns the errorx.Manager.
SelfServiceErrorManager() *Manager
}
)
func NewManager(d managerDependencies) *Manager {
return &Manager{d: d}
}
// Create is a simple helper that saves all errors in the store and returns the
// error url, appending the error ID.
func (m *Manager) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) (string, error) {
m.d.Logger().WithError(err).WithRequest(r).Errorf("An error occurred and is being forwarded to the error user interface.")
id, addErr := m.d.SelfServiceErrorPersister().CreateErrorContainer(ctx, m.d.GenerateCSRFToken(r), err)
if addErr != nil {
return "", addErr
}
q := url.Values{}
q.Set("id", id.String())
return urlx.CopyWithQuery(m.d.Config().SelfServiceFlowErrorURL(ctx), q).String(), nil
}
// Forward is a simple helper that saves all errors in the store and forwards the HTTP Request
// to the error url, appending the error ID.
func (m *Manager) Forward(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
if x.IsJSONRequest(r) {
m.d.Writer().WriteError(w, r, err)
return
}
to, errCreate := m.Create(ctx, w, r, err)
if errCreate != nil {
// Everything failed. Resort to standard error output.
m.d.Logger().WithError(errCreate).WithRequest(r).Error("Failed to create error container.")
m.d.Writer().WriteError(w, r, err)
return
}
if x.AcceptsJSON(r) {
m.d.Writer().WriteError(w, r, err)
return
}
http.Redirect(w, r, to, http.StatusSeeOther)
}

View File

@@ -0,0 +1,33 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type (
Persister interface {
// CreateErrorContainer adds an error to the manager and returns a
// unique identifier or an error if insertion fails.
CreateErrorContainer(ctx context.Context, csrfToken string, err error) (uuid.UUID, error)
// ReadErrorContainer returns an error by its unique identifier and
// marks the error as read. If an error occurs during retrieval the
// second return parameter is an error.
ReadErrorContainer(ctx context.Context, id uuid.UUID) (*ErrorContainer, error)
// ClearErrorContainers clears read containers that are older than a
// certain amount of time. If force is set to true, unread errors will
// be cleared as well.
ClearErrorContainers(ctx context.Context, olderThan time.Duration, force bool) error
}
PersistenceProvider interface {
SelfServiceErrorPersister() Persister
}
)

View File

@@ -0,0 +1,88 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package errorx
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/persistence"
"github.com/ory/x/sqlcon"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/kratos/x"
)
func TestPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
toJSON := func(t *testing.T, in interface{}) string {
out, err := json.Marshal(in)
require.NoError(t, err)
return string(out)
}
return func(t *testing.T) {
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=not found", func(t *testing.T) {
_, err := p.ReadErrorContainer(ctx, x.NewUUID())
require.Error(t, err)
})
t.Run("case=en- and decode properly", func(t *testing.T) {
actualID, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
require.NoError(t, err)
actual, err := p.ReadErrorContainer(ctx, actualID)
require.NoError(t, err)
assert.JSONEq(t, `{"code":404,"status":"Not Found","reason":"foobar","message":"The requested resource could not be found"}`, gjson.Get(toJSON(t, actual), "error").String(), toJSON(t, actual))
})
t.Run("case=clear", func(t *testing.T) {
actualID, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
require.NoError(t, err)
_, err = p.ReadErrorContainer(ctx, actualID)
require.NoError(t, err)
// The row must age past the one-second cutoff before ClearErrorContainers
// removes it, and ReadErrorContainer refreshes seen_at on every read, so
// polling would keep resetting the age. Wait once instead. The extra 0.5s
// covers MySQL's one-second timestamp resolution.
time.Sleep(time.Second + time.Millisecond*500)
require.NoError(t, p.ClearErrorContainers(ctx, time.Second, false))
got, err := p.ReadErrorContainer(ctx, actualID)
require.Error(t, err, "%+v", got)
})
t.Run("case=network", func(t *testing.T) {
t.Run("can not read error from another network", func(t *testing.T) {
created, err := p.CreateErrorContainer(ctx, "nosurf", herodot.ErrNotFound().WithReason("foobar"))
require.NoError(t, err)
_, other := testhelpers.NewNetwork(t, ctx, p)
_, err = other.ReadErrorContainer(ctx, created)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
t.Run("can not clear another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
require.NoError(t, other.ClearErrorContainers(ctx, time.Second, true))
c, err := p.ReadErrorContainer(ctx, created)
require.NoError(t, err)
assert.Contains(t, string(c.Errors), "foobar")
})
})
})
}
}

View File

@@ -0,0 +1,10 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/flow/method.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"method": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"github.com/ory/kratos/ui/container"
)
// swagger:ignore
type MethodConfigurator interface {
container.NodeGetter
container.ErrorParser
// form.NodeSetter
// form.NodeUnsetter
container.ValueSetter
container.Resetter
container.MessageResetter
container.CSRFSetter
container.FieldSorter
}

View File

@@ -0,0 +1,350 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"encoding/base64"
"net/url"
"github.com/ory/herodot"
"github.com/gofrs/uuid"
"github.com/ory/x/urlx"
)
// swagger:model continueWith
type ContinueWith interface {
//swagger:ignore
GetAction() string
}
// swagger:enum ContinueWithActionSetOrySessionToken
type ContinueWithActionSetOrySessionToken string
const (
ContinueWithActionSetOrySessionTokenString ContinueWithActionSetOrySessionToken = "set_ory_session_token" // #nosec G101 -- only a key constant
)
var _ ContinueWith = new(ContinueWithSetOrySessionToken)
// Indicates that a session was issued, and the application should use this token for authenticated requests
//
// swagger:model continueWithSetOrySessionToken
type ContinueWithSetOrySessionToken struct {
// Action will always be `set_ory_session_token`
//
// required: true
Action ContinueWithActionSetOrySessionToken `json:"action"`
// Token is the token of the session
//
// required: true
OrySessionToken string `json:"ory_session_token"`
}
func (ContinueWithSetOrySessionToken) AppendTo(url.Values) url.Values {
return nil
}
func NewContinueWithSetToken(t string) *ContinueWithSetOrySessionToken {
return &ContinueWithSetOrySessionToken{
Action: ContinueWithActionSetOrySessionTokenString,
OrySessionToken: t,
}
}
func (c ContinueWithSetOrySessionToken) GetAction() string {
return string(c.Action)
}
// swagger:enum ContinueWithActionShowVerificationUI
type ContinueWithActionShowVerificationUI string
// #nosec G101 -- only a key constant
const (
ContinueWithActionShowVerificationUIString ContinueWithActionShowVerificationUI = "show_verification_ui"
)
var _ ContinueWith = new(ContinueWithVerificationUI)
// Indicates, that the UI flow could be continued by showing a verification ui
//
// swagger:model continueWithVerificationUi
type ContinueWithVerificationUI struct {
// Action will always be `show_verification_ui`
//
// required: true
Action ContinueWithActionShowVerificationUI `json:"action"`
// Flow contains the ID of the verification flow
//
// required: true
Flow ContinueWithVerificationUIFlow `json:"flow"`
}
func (c ContinueWithVerificationUI) GetAction() string {
return string(c.Action)
}
// swagger:model continueWithVerificationUiFlow
type ContinueWithVerificationUIFlow struct {
// The ID of the verification flow
//
// required: true
ID uuid.UUID `json:"id"`
// The address that should be verified in this flow
//
// required: true
VerifiableAddress string `json:"verifiable_address"`
// The URL of the verification flow
//
// If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.
//
// required: false
URL string `json:"url,omitempty"`
}
func NewContinueWithVerificationUI(id uuid.UUID, address, url string) *ContinueWithVerificationUI {
return &ContinueWithVerificationUI{
Action: ContinueWithActionShowVerificationUIString,
Flow: ContinueWithVerificationUIFlow{
ID: id,
VerifiableAddress: address,
URL: url,
},
}
}
func (c ContinueWithVerificationUI) AppendTo(src *url.URL) *url.URL {
values := src.Query()
values.Set("flow", c.Flow.ID.String())
return urlx.CopyWithQuery(src, values)
}
type FlowWithContinueWith interface {
Flow
AddContinueWith(ContinueWith)
ContinueWith() []ContinueWith
}
// swagger:enum ContinueWithActionShowSettingsUI
type ContinueWithActionShowSettingsUI string
// #nosec G101 -- only a key constant
const (
ContinueWithActionShowSettingsUIString ContinueWithActionShowSettingsUI = "show_settings_ui"
)
var _ ContinueWith = new(ContinueWithSettingsUI)
// Indicates, that the UI flow could be continued by showing a settings ui
//
// swagger:model continueWithSettingsUi
type ContinueWithSettingsUI struct {
// Action will always be `show_settings_ui`
//
// required: true
Action ContinueWithActionShowSettingsUI `json:"action"`
// Flow contains the ID of the settings flow
//
// required: true
Flow ContinueWithSettingsUIFlow `json:"flow"`
}
func (c ContinueWithSettingsUI) GetAction() string {
return string(c.Action)
}
// swagger:model continueWithSettingsUiFlow
type ContinueWithSettingsUIFlow struct {
// The ID of the settings flow
//
// required: true
ID uuid.UUID `json:"id"`
// The URL of the settings flow
//
// If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.
//
// required: false
URL string `json:"url,omitempty"`
}
func NewContinueWithSettingsUI(f Flow, redirectTo string) *ContinueWithSettingsUI {
return &ContinueWithSettingsUI{
Action: ContinueWithActionShowSettingsUIString,
Flow: ContinueWithSettingsUIFlow{
ID: f.GetID(),
URL: redirectTo,
},
}
}
// swagger:enum ContinueWithActionShowRecoveryUI
type ContinueWithActionShowRecoveryUI string
// #nosec G101 -- only a key constant
const (
ContinueWithActionShowRecoveryUIString ContinueWithActionShowRecoveryUI = "show_recovery_ui"
)
// Indicates, that the UI flow could be continued by showing a recovery ui
//
// swagger:model continueWithRecoveryUi
type ContinueWithRecoveryUI struct {
// Action will always be `show_recovery_ui`
//
// required: true
Action ContinueWithActionShowRecoveryUI `json:"action"`
// Flow contains the ID of the recovery flow
//
// required: true
Flow ContinueWithRecoveryUIFlow `json:"flow"`
}
// swagger:model continueWithRecoveryUiFlow
type ContinueWithRecoveryUIFlow struct {
// The ID of the recovery flow
//
// required: true
ID uuid.UUID `json:"id"`
// The URL of the recovery flow
//
// If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.
//
// required: false
URL string `json:"url,omitempty"`
}
func NewContinueWithRecoveryUI(f Flow) *ContinueWithRecoveryUI {
return &ContinueWithRecoveryUI{
Action: ContinueWithActionShowRecoveryUIString,
Flow: ContinueWithRecoveryUIFlow{
ID: f.GetID(),
},
}
}
func (c ContinueWithRecoveryUI) GetAction() string {
return string(c.Action)
}
// swagger:enum ContinueWithActionRedirectBrowserTo
type ContinueWithActionRedirectBrowserTo string
// #nosec G101 -- only a key constant
const (
ContinueWithActionRedirectBrowserToString ContinueWithActionRedirectBrowserTo = "redirect_browser_to"
)
// Indicates, that the UI flow could be continued by showing a recovery ui
//
// swagger:model continueWithRedirectBrowserTo
type ContinueWithRedirectBrowserTo struct {
// Action will always be `redirect_browser_to`
//
// required: true
Action ContinueWithActionRedirectBrowserTo `json:"action"`
// The URL to redirect the browser to
//
// required: true
RedirectTo string `json:"redirect_browser_to"`
}
func NewContinueWithRedirectBrowserTo(redirectTo string) *ContinueWithRedirectBrowserTo {
return &ContinueWithRedirectBrowserTo{
Action: ContinueWithActionRedirectBrowserToString,
RedirectTo: redirectTo,
}
}
func (c ContinueWithRedirectBrowserTo) GetAction() string {
return string(c.Action)
}
// swagger:enum ContinueWithActionShowPINEntryUI
type ContinueWithActionShowPINEntryUI string
// #nosec G101 -- only an action constant
const (
ContinueWithActionShowPINEntryUIString ContinueWithActionShowPINEntryUI = "show_pin_entry_ui"
)
var _ ContinueWith = new(ContinueWithDeviceAuthnPINEntryUI)
// Instructs the client to show its PIN entry UI
//
// Returned after a PIN-protected DeviceAuthn enrollment or secret rotation.
// It carries the one-time HPKE-sealed pin_secret, which the device opens with
// the transport private key it generated for this enrollment and binds to the
// user's PIN. The plaintext secret never leaves the device; the server only
// ever stores its at-rest ciphertext.
//
// The enrolled key's client_key_id is not included: it is the SHA-256 fingerprint
// of the device's own public key, which the device derives locally. Non-PIN keys
// need no client action at all and therefore produce no continue_with.
//
// swagger:model continueWithDeviceAuthnPinEntryUi
type ContinueWithDeviceAuthnPINEntryUI struct {
// Action will always be `show_pin_entry_ui`
//
// required: true
Action ContinueWithActionShowPINEntryUI `json:"action"`
// Data carries the sealed pin_secret material.
//
// required: true
Data ContinueWithDeviceAuthnPINEntryUIData `json:"data"`
}
// The one-time HPKE-sealed pin_secret material
//
// Contains the encapsulated key and the sealed ciphertext that the device
// opens with the X25519 transport private key it generated for this
// enrollment or rotation. Open it with HPKE (RFC 9180) using the suite
// DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM, the ASCII info
// string "ory/deviceauthn/pin-secret/v1", and the key's client_key_id (its
// ASCII hex form) as the AAD.
//
// swagger:model continueWithDeviceAuthnPinEntryUiData
type ContinueWithDeviceAuthnPINEntryUIData struct {
// The base64-encoded HPKE encapsulated key (the `enc` output of the seal
// operation).
//
// required: true
Enc string `json:"enc"`
// The base64-encoded HPKE ciphertext of the sealed pin_secret.
//
// required: true
Ciphertext string `json:"ciphertext"`
}
func NewContinueWithDeviceAuthnPINEntryUI(enc, ciphertext []byte) *ContinueWithDeviceAuthnPINEntryUI {
return &ContinueWithDeviceAuthnPINEntryUI{
Action: ContinueWithActionShowPINEntryUIString,
Data: ContinueWithDeviceAuthnPINEntryUIData{
Enc: base64.StdEncoding.EncodeToString(enc),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
},
}
}
func (c ContinueWithDeviceAuthnPINEntryUI) GetAction() string {
return string(c.Action)
}
func ErrorWithContinueWith(err *herodot.DefaultError, continueWith ...ContinueWith) *herodot.DefaultError {
if err.DetailsField == nil {
err.DetailsField = map[string]interface{}{}
}
err.DetailsField["continue_with"] = continueWith
return err
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"encoding/json"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/kratos/identity"
"github.com/ory/x/sqlxx"
)
const internalContextDuplicateCredentialsPath = "registration_duplicate_credentials"
type DuplicateCredentialsData struct {
CredentialsType identity.CredentialsType
CredentialsConfig sqlxx.JSONRawMessage
DuplicateIdentifier string
}
// SetDuplicateCredentials sets the duplicate credentials data in the flow's internal context.
func SetDuplicateCredentials(flow InternalContexter, creds DuplicateCredentialsData) error {
if flow.GetInternalContext() == nil {
flow.EnsureInternalContext()
}
bytes, err := sjson.SetBytes(
flow.GetInternalContext(),
internalContextDuplicateCredentialsPath,
creds,
)
if err != nil {
return err
}
flow.SetInternalContext(bytes)
return nil
}
// DuplicateCredentials returns the duplicate credentials data from the flow's internal context.
func DuplicateCredentials(flow InternalContexter) (*DuplicateCredentialsData, error) {
if flow.GetInternalContext() == nil {
flow.EnsureInternalContext()
}
raw := gjson.GetBytes(flow.GetInternalContext(), internalContextDuplicateCredentialsPath)
if !raw.IsObject() {
return nil, nil
}
var creds DuplicateCredentialsData
err := json.Unmarshal([]byte(raw.Raw), &creds)
return &creds, err
}

223
selfservice/flow/error.go Normal file
View File

@@ -0,0 +1,223 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/swagger"
"github.com/ory/x/clock"
"github.com/ory/x/logrusx"
"github.com/ory/x/urlx"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/text"
)
var (
ErrStrategyNotResponsible = errors.New("strategy is not responsible for this request")
ErrCompletedByStrategy = errors.New("flow response completed by strategy")
ErrStrategyAsksToReturnToUI = errors.New("flow strategy is redirecting to the ui")
)
// Is sent when a flow is replaced by a different flow of the same class
//
// swagger:model errorFlowReplaced
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type errorFlowReplaced struct {
Error swagger.GenericError `json:"error"`
// The flow ID that should be used for the new flow as it contains the correct messages.
FlowID uuid.UUID `json:"use_flow_id"`
}
// ReplacedError is sent when a flow is replaced by a different flow of the same class
type ReplacedError struct {
*herodot.DefaultError `json:"error"`
// The flow ID that should be used for the new flow as it contains the correct messages.
FlowID uuid.UUID `json:"use_flow_id"`
flow Flow
// TODO: This error could be enhanced by providing a "flow class" (e.g. "Recovery", "Settings", "Verification", "Login", etc.)
}
func (e *ReplacedError) WithFlow(flow Flow) *ReplacedError {
e.FlowID = flow.GetID()
e.flow = flow
return e
}
func (e *ReplacedError) GetFlow() Flow {
return e.flow
}
func (e *ReplacedError) EnhanceJSONError() interface{} {
return e
}
func NewFlowReplacedError(message *text.Message) *ReplacedError {
return &ReplacedError{
DefaultError: nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowReplaced).
WithError("self-service flow replaced").
WithReason(message.Text),
}
}
// Is sent when a flow is expired
//
// swagger:model selfServiceFlowExpiredError
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type selfServiceFlowExpiredError struct {
Error swagger.GenericError `json:"error"`
// When the flow has expired
ExpiredAt time.Time `json:"expired_at"`
// Please use the "expired_at" field instead to have a more accurate result.
//
// Deprecated: true
Since time.Duration `json:"since"`
// The flow ID that should be used for the new flow as it contains the correct messages.
FlowID uuid.UUID `json:"use_flow_id"`
}
// ExpiredError is sent when a flow is expired
type ExpiredError struct {
*herodot.DefaultError `json:"error"`
// When the flow has expired
ExpiredAt time.Time `json:"expired_at"`
// DEPRECATED: Please use the "expired_at" field instead to have a more accurate result.
Since time.Duration `json:"since"`
// The flow ID that should be used for the new flow as it contains the correct messages.
FlowID uuid.UUID `json:"use_flow_id"`
flow Flow
}
func (e *ExpiredError) Unwrap() error {
return e.DefaultError
}
func (e *ExpiredError) WithContinueWith(continueWith ...ContinueWith) *ExpiredError {
e.DefaultError = ErrorWithContinueWith(e.DefaultError, continueWith...)
return e
}
func (e *ExpiredError) WithFlow(flow Flow) *ExpiredError {
e.FlowID = flow.GetID()
e.flow = flow
return e
}
func (e *ExpiredError) GetFlow() Flow {
return e.flow
}
func (e *ExpiredError) EnhanceJSONError() interface{} {
return e
}
func NewFlowExpiredError(c clock.Clock, at time.Time) *ExpiredError {
ago := c.Now().Sub(at)
return &ExpiredError{
ExpiredAt: at.UTC(),
Since: ago,
DefaultError: nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
WithError("self-service flow expired").
WithReasonf("The self-service flow expired %.2f minutes ago, initialize a new one.", ago.Minutes()),
}
}
// Is sent when a flow requires a browser to change its location.
//
// swagger:model errorBrowserLocationChangeRequired
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type errorBrowserLocationChangeRequired struct {
Error swagger.ErrorGeneric `json:"error"`
// Points to where to redirect the user to next.
RedirectBrowserTo string `json:"redirect_browser_to"`
}
// BrowserLocationChangeRequiredError is sent when a flow requires a browser to change its location.
type BrowserLocationChangeRequiredError struct {
*herodot.DefaultError `json:"error"`
// Points to where to redirect the user to next.
RedirectBrowserTo string `json:"redirect_browser_to"`
}
func (e *BrowserLocationChangeRequiredError) EnhanceJSONError() interface{} {
return e
}
func NewBrowserLocationChangeRequiredError(redirectTo string) *BrowserLocationChangeRequiredError {
return &BrowserLocationChangeRequiredError{
RedirectBrowserTo: redirectTo,
DefaultError: &herodot.DefaultError{
IDField: text.ErrIDSelfServiceBrowserLocationChangeRequiredError,
CodeField: http.StatusUnprocessableEntity,
StatusField: http.StatusText(http.StatusUnprocessableEntity),
ReasonField: fmt.Sprintf("In order to complete this flow please redirect the browser to: %s", redirectTo),
DebugField: "",
ErrorField: "browser location change required",
},
}
}
func HandleHookError(_ http.ResponseWriter, r *http.Request, f Flow, traits identity.Traits, group node.UiNodeGroup, flowError error, logger logrusx.Provider, csrf nosurfx.CSRFTokenGeneratorProvider) error {
if f != nil {
if traits != nil {
cont, err := container.NewFromStruct("", group, traits, "traits")
if err != nil {
logger.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.GetUI().Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
}
if f.GetType() == TypeBrowser {
f.GetUI().SetCSRF(csrf.GenerateCSRFToken(r))
}
}
return flowError
}
func GetFlowExpiredRedirectURL(ctx context.Context, config *config.Config, route, returnTo string) *url.URL {
redirectURL := urlx.AppendPaths(config.SelfPublicURL(ctx), route)
if returnTo != "" {
redirectURL = urlx.CopyWithQuery(redirectURL, url.Values{"return_to": {returnTo}})
}
return redirectURL
}

View File

@@ -0,0 +1,165 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"encoding/json"
"net/http"
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
type testCSRFTokenGenerator struct{}
func (t *testCSRFTokenGenerator) GenerateCSRFToken(_ *http.Request) string {
return "csrf_token_value"
}
// testFlow is a minimalistic flow implementation to satisfy interface and is used only in tests.
type testFlow struct {
// ID represents the flow's unique ID.
//
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id" rw:"r"`
// Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction.
//
// required: true
Type Type `json:"type" db:"type" faker:"flow_type"`
// 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"`
// UI contains data which must be shown in the user interface.
//
// required: true
UI *container.Container `json:"ui" db:"ui"`
// Flow State
//
// The state represents the state of the verification flow.
//
// - choose_method: ask the user to choose a method (e.g. recover account via email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the recovery challenge was passed.
//
// required: true
State State `json:"state" db:"state"`
}
func (t *testFlow) GetID() uuid.UUID {
return t.ID
}
func (t *testFlow) GetType() Type {
return t.Type
}
func (t *testFlow) GetRequestURL() string {
return t.RequestURL
}
func (t *testFlow) AppendTo(url *url.URL) *url.URL {
return AppendFlowTo(url, t.ID)
}
func (t *testFlow) GetUI() *container.Container {
return t.UI
}
func (t *testFlow) GetState() State {
return t.State
}
func (t *testFlow) GetFlowName() FlowName {
return FlowName("test")
}
func (t *testFlow) SetState(state State) {
t.State = state
}
func (t *testFlow) GetTransientPayload() json.RawMessage {
return nil
}
func newTestFlow(r *http.Request, flowType Type) Flow {
id := x.NewUUID()
requestURL := x.RequestURL(r).String()
ui := &container.Container{
Method: "POST",
Action: "/test",
}
ui.Nodes.Append(node.NewInputField("traits.username", nil, node.PasswordGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute))
ui.Nodes.Append(node.NewInputField("traits.password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute))
return &testFlow{
ID: id,
UI: ui,
RequestURL: requestURL,
Type: flowType,
}
}
func prepareTraits(username, password string) identity.Traits {
payload := struct {
Username string `json:"username"`
Password string `json:"password"`
}{username, password}
data, _ := json.Marshal(payload)
return data
}
func TestHandleHookError(t *testing.T) {
r := &http.Request{URL: &url.URL{RawQuery: ""}}
logger := logrusx.New("kratos", "test", logrusx.ForceLevel(logrus.FatalLevel))
l := &x.BasicRegistry{L: logger, C: httpx.NewResilientClient(), T: otelx.NewNoop()}
csrf := testCSRFTokenGenerator{}
f := newTestFlow(r, TypeBrowser)
tr := prepareTraits("foo", "bar")
t.Run("case=fill_in_traits", func(t *testing.T) {
ve := schema.NewValidationListError([]*schema.ValidationError{schema.NewHookValidationError("traits.username", "invalid username", text.Messages{})})
err := HandleHookError(nil, r, f, tr, node.PasswordGroup, ve, l, &csrf)
assert.ErrorIs(t, err, ve)
if assert.NotEmpty(t, f.GetUI()) {
ui := f.GetUI()
assert.Len(t, ui.Nodes, 3)
assert.ElementsMatch(t, ui.Nodes,
node.Nodes{
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "traits.username", Type: node.InputAttributeTypeText, FieldValue: "foo", Required: true}, Meta: &node.Meta{}},
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "traits.password", Type: node.InputAttributeTypePassword, FieldValue: "bar", Required: true}, Meta: &node.Meta{}},
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "csrf_token", Type: node.InputAttributeTypeHidden, FieldValue: "csrf_token_value", Required: true}},
})
}
})
t.Run("case=unmarshal_fail", func(t *testing.T) {
ve := schema.NewValidationListError([]*schema.ValidationError{schema.NewHookValidationError("traits.username", "invalid username", text.Messages{})})
err := HandleHookError(nil, r, f, []byte("garbage"), node.PasswordGroup, ve, l, &csrf)
var jsonErr *json.SyntaxError
assert.ErrorAs(t, err, &jsonErr)
})
}

70
selfservice/flow/flow.go Normal file
View File

@@ -0,0 +1,70 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"encoding/json"
"net/http"
"net/url"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/x/sqlxx"
"github.com/ory/kratos/x/redir"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/urlx"
)
func AppendFlowTo(src *url.URL, id uuid.UUID) *url.URL {
return urlx.CopyWithQuery(src, url.Values{"flow": {id.String()}})
}
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
}
type Flow interface {
GetID() uuid.UUID
GetType() Type
GetRequestURL() string
AppendTo(*url.URL) *url.URL
GetUI() *container.Container
GetState() State
SetState(State)
GetFlowName() FlowName
GetTransientPayload() json.RawMessage
}
type FlowWithRedirect interface {
SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []redir.SecureRedirectOption)
}
type InternalContexter interface {
EnsureInternalContext()
GetInternalContext() sqlxx.JSONRawMessage
SetInternalContext(sqlxx.JSONRawMessage)
}
type OAuth2ChallengeProvider interface {
GetOAuth2LoginChallenge() sqlxx.NullString
}
// HydraLoginRequestProvider is implemented by flows that can carry the OAuth2
// login request that initiated them.
type HydraLoginRequestProvider interface {
GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest
}

View File

@@ -0,0 +1,60 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"database/sql"
"database/sql/driver"
"net/url"
"github.com/ory/kratos/driver/config"
)
// swagger:type string
type IdentitySchema string
// Scan implements the Scanner interface.
func (is *IdentitySchema) Scan(value any) error {
var v sql.NullString
if err := (&v).Scan(value); err != nil {
return err
}
*is = IdentitySchema(v.String)
return nil
}
// Value implements the driver Valuer interface.
func (is *IdentitySchema) Value() (driver.Value, error) {
if is == nil || len(*is) == 0 {
return sql.NullString{}.Value()
}
return sql.NullString{Valid: true, String: string(*is)}.Value()
}
// URL returns the URL of the identity schema, or the default identity traits
// schema URL if the schema is empty.
func (is *IdentitySchema) URL(ctx context.Context, config *config.Config) (*url.URL, error) {
if is == nil || len(*is) == 0 {
return config.DefaultIdentityTraitsSchemaURL(ctx)
}
schemas, err := config.IdentityTraitsSchemas(ctx)
if err != nil {
return nil, err
}
schema, err := schemas.FindSchemaByID(string(*is))
if err != nil {
return nil, err
}
return config.ParseURI(schema.URL)
}
// ID returns the ID of the identity schema, or the default identity schema ID.
func (is *IdentitySchema) ID(ctx context.Context, config *config.Config) string {
if is == nil || len(*is) == 0 {
return config.DefaultIdentityTraitsSchemaID(ctx)
}
return string(*is)
}

View File

@@ -0,0 +1,36 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"net/http"
"net/url"
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/kratos/x"
)
func TestGetFlowID(t *testing.T) {
_, err := GetFlowID(&http.Request{URL: &url.URL{RawQuery: ""}})
require.Error(t, err)
assert.Contains(t,
errors.Cause(err).(*herodot.DefaultError).ReasonField,
"flow query parameter is missing or malformed")
_, err = GetFlowID(&http.Request{URL: &url.URL{RawQuery: "flow=not-uuid"}})
require.Error(t, err)
assert.Contains(t,
errors.Cause(err).(*herodot.DefaultError).ReasonField,
"flow query parameter is missing or malformed")
expected := x.NewUUID()
actual, err := GetFlowID(&http.Request{URL: &url.URL{RawQuery: "flow=" + expected.String()}})
require.NoError(t, err)
assert.Equal(t, expected.String(), actual.String())
}

View File

@@ -0,0 +1,59 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/kratos/identity"
"github.com/ory/x/sqlxx"
)
// InternalContextKeyCourierBaseURL is the key under which recovery and
// verification flows stash the base URL captured at flow init. Email
// senders read it back at courier-send time to build the link domain. The
// key is top-level (not strategy-scoped) because the value is flow-wide.
const InternalContextKeyCourierBaseURL = "courier_base_url"
// InternalContextKeyRequestBaseURL is the key under which login,
// registration, and settings flows stash the customer-facing base URL
// captured at flow init. The OIDC/SAML strategy reads it back at
// provider-submit time and embeds it in the encrypted state, so the
// social-sign-in callback can be redirected onto the host the user
// started on (Ory CLI/Tunnel, custom domain) instead of the bare
// projects.oryapis host the IdP redirects to. The key is top-level (not
// strategy-scoped) because the value is flow-wide.
const InternalContextKeyRequestBaseURL = "request_base_url"
func PrefixInternalContextKey(t identity.CredentialsType, suffix string) string {
return string(t) + "_" + suffix
}
// SetRequestBaseURL stashes the base URL captured at flow init (by a
// proxy-aware middleware, via x.WithBaseURL on the request context) into
// the flow's InternalContext under InternalContextKeyRequestBaseURL. Empty
// input is a no-op (preserves the "no rewrite" path). Inputs longer than
// 8192 bytes are rejected so a pathological header cannot bloat the row —
// the same implicit ceiling the courier base URL uses.
func SetRequestBaseURL(f InternalContexter, baseURL string) error {
if baseURL == "" || len(baseURL) > 8192 {
return nil
}
f.EnsureInternalContext()
out, err := sjson.SetBytes(f.GetInternalContext(), InternalContextKeyRequestBaseURL, baseURL)
if err != nil {
return errors.WithStack(err)
}
f.SetInternalContext(sqlxx.JSONRawMessage(out))
return nil
}
// GetRequestBaseURL returns the base URL captured at flow init, or the
// empty string when nothing was captured (the OIDC/SAML callback then does
// not rewrite — matching plain projects.oryapis traffic).
func GetRequestBaseURL(f InternalContexter) string {
return gjson.GetBytes(f.GetInternalContext(), InternalContextKeyRequestBaseURL).String()
}

View File

@@ -0,0 +1,52 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/sqlxx"
)
type fakeInternalContexter struct {
ic sqlxx.JSONRawMessage
}
func (f *fakeInternalContexter) EnsureInternalContext() {
if len(f.ic) == 0 {
f.ic = sqlxx.JSONRawMessage("{}")
}
}
func (f *fakeInternalContexter) GetInternalContext() sqlxx.JSONRawMessage { return f.ic }
func (f *fakeInternalContexter) SetInternalContext(b sqlxx.JSONRawMessage) { f.ic = b }
func TestRequestBaseURLInternalContext(t *testing.T) {
t.Run("round-trips through InternalContext", func(t *testing.T) {
f := &fakeInternalContexter{}
require.NoError(t, SetRequestBaseURL(f, "http://localhost:4000"))
assert.Equal(t, "http://localhost:4000", GetRequestBaseURL(f))
assert.Contains(t, string(f.GetInternalContext()), InternalContextKeyRequestBaseURL)
})
t.Run("empty input is a no-op and leaves no key", func(t *testing.T) {
f := &fakeInternalContexter{ic: sqlxx.JSONRawMessage("{}")}
require.NoError(t, SetRequestBaseURL(f, ""))
assert.Empty(t, GetRequestBaseURL(f))
assert.Equal(t, "{}", string(f.GetInternalContext()))
})
t.Run("oversized input is rejected (row-bloat guard)", func(t *testing.T) {
f := &fakeInternalContexter{}
require.NoError(t, SetRequestBaseURL(f, "https://"+strings.Repeat("a", 8193)))
assert.Empty(t, GetRequestBaseURL(f))
})
t.Run("missing key returns empty", func(t *testing.T) {
assert.Empty(t, GetRequestBaseURL(&fakeInternalContexter{ic: sqlxx.JSONRawMessage(`{"other":"x"}`)}))
})
}

View File

@@ -0,0 +1,18 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"github.com/pkg/errors"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
)
func CheckAAL(f *Flow, expected identity.AuthenticatorAssuranceLevel) error {
if f.RequestedAAL != expected {
return errors.WithStack(flow.ErrStrategyNotResponsible)
}
return nil
}

View File

@@ -0,0 +1,20 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
)
func TestCheckAAL(t *testing.T) {
f := &login.Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
assert.NoError(t, login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1))
assert.ErrorIs(t, login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2), flow.ErrStrategyNotResponsible)
}

View File

@@ -0,0 +1,208 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"net/http"
"net/url"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/x"
)
var ErrHookAbortFlow = errors.New("aborted login hook execution")
func ErrAlreadyLoggedIn() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDAlreadyLoggedIn).WithError("you are already logged in").WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
}
func ErrAddressNotVerified() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDAddressNotVerified).WithError("your email or phone address is not yet verified").WithReason("Your account's email or phone address are not verified yet. Please check your email or phone inbox or re-request verification.")
}
// ErrSessionHasAALAlready is returned when one attempts to upgrade the AAL of an active session which already has that AAL.
func ErrSessionHasAALAlready() *herodot.DefaultError {
return herodot.ErrUnauthorized().WithID(text.ErrIDSessionHasAALAlready).WithError("session has the requested authenticator assurance level already").WithReason("The session has the requested AAL already.")
}
// ErrSessionRequiredForHigherAAL is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
func ErrSessionRequiredForHigherAAL() *herodot.DefaultError {
return herodot.ErrUnauthorized().WithID(text.ErrIDSessionRequiredForHigherAAL).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
}
type (
errorHandlerDependencies interface {
clock.Provider
errorx.ManagementProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
config.Provider
sessiontokenexchange.PersistenceProvider
hydra.Provider
FlowPersistenceProvider
HandlerProvider
}
ErrorHandlerProvider interface{ LoginFlowErrorHandler() *ErrorHandler }
ErrorHandler struct {
d errorHandlerDependencies
}
)
func NewFlowErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}
func (s *ErrorHandler) PrepareReplacementForExpiredFlow(w http.ResponseWriter, r *http.Request, f *Flow, err error) (*flow.ExpiredError, error) {
errExpired := new(flow.ExpiredError)
if !errors.As(err, &errExpired) {
return nil, nil
}
// create new flow because the old one is not valid
newFlow, err := s.d.LoginHandler().FromOldFlow(w, r, *f)
if err != nil {
return nil, err
}
newFlow.UI.Messages.Add(text.NewErrorValidationLoginFlowExpired(s.d.Clock(), errExpired.ExpiredAt))
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), newFlow); err != nil {
return nil, err
}
return errExpired.WithFlow(newFlow), nil
}
func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f *Flow, ct identity.CredentialsType, group node.UiNodeGroup, err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.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("login_flow", f.ToLoggerField())
logger.Info("Encountered self-service login error.")
if f == nil {
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginFailed(r.Context(), uuid.Nil, "", "", "", false, err))
s.forward(w, r, nil, err)
return
}
span.SetAttributes(attribute.String("flow_id", f.ID.String()))
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginFailed(r.Context(), f.ID, string(f.Type), ct.String(), string(f.RequestedAAL), f.Refresh, err))
if expired, inner := s.PrepareReplacementForExpiredFlow(w, r, f, err); inner != nil {
s.WriteFlowError(w, r, f, ct, group, inner)
return
} else if expired != nil {
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().SelfServiceFlowLoginUI(r.Context())).String(), http.StatusSeeOther)
}
return
}
f.UI.ResetMessages()
if err := f.UI.ParseError(group, err); err != nil {
s.forward(w, r, f, err)
return
}
if err := sortNodes(r.Context(), f.UI.Nodes); err != nil {
s.forward(w, r, f, err)
return
}
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil {
s.forward(w, r, f, err)
return
}
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())).String(), http.StatusSeeOther)
return
}
codes, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID)
if f.Type == flow.TypeAPI && hasCode && group == node.OpenIDConnectGroup {
returnTo, err := url.Parse(f.ReturnTo)
if err != nil {
s.forward(w, r, f, errors.WithStack(err))
return
}
q := returnTo.Query()
q.Set("code", codes.ReturnToCode)
returnTo.RawQuery = q.Encode()
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
return
}
updatedFlow, innerErr := s.d.LoginFlowPersister().GetLoginFlow(r.Context(), f.ID)
if innerErr != nil {
s.forward(w, r, updatedFlow, innerErr)
return
}
// If the flow has an OAuth2LoginChallenge, we try to fetch the login request from Hydra because we return the flow object as JSON.
if updatedFlow.OAuth2LoginChallenge != "" {
hlr, err := s.d.Hydra().GetLoginRequest(ctx, string(updatedFlow.OAuth2LoginChallenge))
if err != nil {
// We don't redirect back to the third party on errors because Hydra doesn't
// give us the 3rd party return_uri when it redirects to the login UI.
s.forward(w, r, updatedFlow, err)
return
}
updatedFlow.HydraLoginRequest = hlr
}
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), updatedFlow)
}
func (s *ErrorHandler) forward(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(r.Context(), w, r, err)
return
}
if rr.Type == flow.TypeAPI {
s.d.Writer().WriteErrorCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), err)
} else {
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
}
}

View File

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

View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"github.com/ory/kratos/session"
)
func CheckAALForTest(ctx context.Context, e *HookExecutor, s *session.Session, flow *Flow) error {
return e.checkAAL(ctx, s, flow)
}

View File

@@ -0,0 +1,54 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"github.com/ory/kratos/text"
"github.com/ory/x/jsonschemax"
"github.com/ory/kratos/schema"
)
func GetIdentifierLabelFromSchema(ctx context.Context, schemaURL string, disallowRefs bool) (*text.Message, error) {
runner, err := schema.NewExtensionRunner(ctx)
if err != nil {
return nil, err
}
c, err := schema.NewCompilerWithURL(ctx, schemaURL, disallowRefs)
if err != nil {
return nil, err
}
c.ExtractAnnotations = true
runner.Register(c)
paths, err := jsonschemax.ListPaths(ctx, schemaURL, c)
if err != nil {
return nil, err
}
labels := []jsonschemax.Path{}
for _, path := range paths {
if ext := path.CustomProperties[schema.ExtensionName]; ext != nil {
config, ok := ext.(*schema.ExtensionConfig)
if !ok {
continue
}
if config.Credentials.Password.Identifier ||
config.Credentials.WebAuthn.Identifier ||
config.Credentials.Passkey.DisplayName ||
config.Credentials.TOTP.AccountName ||
config.Credentials.Code.Identifier {
labels = append(labels, path)
}
}
}
metaLabel := text.NewInfoNodeLabelID()
if len(labels) == 1 && labels[0].Title != "" {
metaLabel = text.NewInfoNodeLabelGenerated(labels[0].Title, labels[0].Name)
}
return metaLabel, nil
}

View File

@@ -0,0 +1,158 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"github.com/ory/kratos/text"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"github.com/ory/kratos/schema"
)
func constructSchema(t *testing.T, ecModifier, ucModifier func(*schema.ExtensionConfig)) string {
var emailConfig, usernameConfig schema.ExtensionConfig
if ecModifier != nil {
ecModifier(&emailConfig)
}
if ucModifier != nil {
ucModifier(&usernameConfig)
}
ec, err := json.Marshal(&emailConfig)
require.NoError(t, err)
uc, err := json.Marshal(&usernameConfig)
require.NoError(t, err)
ec, err = sjson.DeleteBytes(ec, "verification")
require.NoError(t, err)
ec, err = sjson.DeleteBytes(ec, "recovery")
require.NoError(t, err)
ec, err = sjson.DeleteBytes(ec, "credentials.code.via")
require.NoError(t, err)
ec, err = sjson.DeleteBytes(ec, "organizations.matcher")
require.NoError(t, err)
uc, err = sjson.DeleteBytes(uc, "verification")
require.NoError(t, err)
uc, err = sjson.DeleteBytes(uc, "recovery")
require.NoError(t, err)
uc, err = sjson.DeleteBytes(uc, "credentials.code.via")
require.NoError(t, err)
uc, err = sjson.DeleteBytes(uc, "organizations.matcher")
require.NoError(t, err)
return "base64://" + base64.StdEncoding.EncodeToString(fmt.Appendf(nil, `
{
"$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"title": "Email",
"type": "string",
"ory.sh/kratos": %s
},
"username": {
"title": "Username",
"type": "string",
"ory.sh/kratos": %s
}
}
}
}
}`, ec, uc))
}
func TestGetIdentifierLabelFromSchema(t *testing.T) {
for _, tc := range []struct {
name string
emailConfig, usernameConfig func(*schema.ExtensionConfig)
expected *text.Message
}{
{
name: "email for password",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Password.Identifier = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "email for webauthn",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.WebAuthn.Identifier = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "email for totp",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.TOTP.AccountName = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "email for code",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Code.Identifier = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "email for passkey",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Passkey.DisplayName = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "email for all",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Password.Identifier = true
c.Credentials.WebAuthn.Identifier = true
c.Credentials.TOTP.AccountName = true
c.Credentials.Code.Identifier = true
},
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
},
{
name: "username works as well",
usernameConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Password.Identifier = true
},
expected: text.NewInfoNodeLabelGenerated("Username", "traits.username"),
},
{
name: "multiple identifiers",
emailConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Password.Identifier = true
},
usernameConfig: func(c *schema.ExtensionConfig) {
c.Credentials.Password.Identifier = true
},
expected: text.NewInfoNodeLabelID(),
},
{
name: "no identifiers",
expected: text.NewInfoNodeLabelID(),
},
} {
t.Run(tc.name, func(t *testing.T) {
label, err := GetIdentifierLabelFromSchema(t.Context(), constructSchema(t, tc.emailConfig, tc.usernameConfig), true)
require.NoError(t, err)
assert.Equal(t, tc.expected, label)
})
}
}

View File

@@ -0,0 +1,349 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"cmp"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"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"
)
// Login Flow
//
// This object represents a login flow. A login flow is initiated at the "Initiate Login API / Browser Flow"
// endpoint by a client.
//
// Once a login flow is completed successfully, a session cookie or session token will be issued.
//
// swagger:model loginFlow
type Flow struct {
// ID represents the flow's unique ID. When performing the login flow, this
// represents the id in the login UI's query parameter: http://<selfservice.flows.login.ui_url>/?flow=<flow_id>
//
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id" rw:"r"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
OrganizationID uuid.NullUUID `json:"organization_id,omitempty" faker:"-" db:"organization_id"`
// Ory OAuth 2.0 Login Challenge.
//
// This value is set using the `login_challenge` query parameter of the registration and login endpoints.
// If set will cooperate with Ory OAuth2 and OpenID to act as an OAuth2 server / OpenID Provider.
OAuth2LoginChallenge sqlxx.NullString `json:"oauth2_login_challenge,omitempty" faker:"-" db:"oauth2_login_challenge_data"`
// HydraLoginRequest is an optional field whose presence indicates that Kratos
// is being used as an identity provider in a Hydra OAuth2 flow. Kratos
// populates this field by retrieving its value from Hydra and it is used by
// the login and consent UIs.
HydraLoginRequest *hydraclientgo.OAuth2LoginRequest `json:"oauth2_login_request,omitempty" faker:"-" db:"-"`
// 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 log in,
// 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 started.
//
// required: true
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
// InternalContext stores internal context used by internals - for example MFA keys.
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
// 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:"-"`
// The active login method
//
// If set contains the login method used. If the flow is new, it is unset.
Active identity.CredentialsType `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"`
// CreatedAt is a helper struct field for gobuffalo.pop.
CreatedAt time.Time `json:"created_at" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// CSRFToken contains the anti-csrf token associated with this flow. Only set for browser flows.
CSRFToken string `json:"-" db:"csrf_token"`
// Refresh stores whether this login flow should enforce re-authentication.
Refresh bool `json:"refresh" db:"forced"`
// RequestedAAL stores if the flow was requested to update the authenticator assurance level.
//
// This value can be one of "aal1", "aal2", "aal3".
RequestedAAL identity.AuthenticatorAssuranceLevel `json:"requested_aal" faker:"len=4" db:"requested_aal"`
// SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed.
// This is only set if the client has requested a session token exchange code, and if the flow is of type "api",
// and only on creating the login flow.
SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"`
// State represents the state of this request:
//
// - choose_method: ask the user to choose a method to sign in with
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the login challenge was passed.
//
// required: true
State State `json:"state" faker:"-" db:"state"`
// Only used internally
IDToken string `json:"-" db:"-"`
// Only used internally
RawIDTokenNonce string `json:"-" db:"-"`
// TransientPayload is used to pass data from the login to hooks and email templates
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
// 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.
ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-" `
// ReturnToVerification contains the redirect URL for the verification flow.
ReturnToVerification string `json:"-" db:"-"`
isAccountLinkingFlow bool `db:"-"`
// IdentitySchema optionally holds the ID of the identity schema that is used
// for this flow. This value can be set by the user when creating the flow and
// should be retained when the flow is saved or converted to another flow.
IdentitySchema flow.IdentitySchema `json:"identity_schema,omitempty" faker:"-" db:"identity_schema_id"`
// TestFlow marks this flow as an admin-created dry-run OIDC test. Test
// flows short-circuit the OIDC callback: no identity is persisted and no
// session is issued. The captured debug data is returned via the derived
// TestContext field below.
//
// Nullable so that existing rows created before the column was added read
// as a SQL NULL (effectively false); see IsTest.
TestFlow sql.NullBool `json:"-" faker:"-" db:"test_flow"`
// TestContext is the derived API view of test-mode data. It is populated
// from InternalContext["test"] during marshal and is nil for regular
// login flows. See test_context.go.
//
// required: false
// readOnly: true
TestContext *TestContext `json:"test_context,omitempty" faker:"-" db:"-"`
}
var (
_ flow.Flow = (*Flow)(nil)
_ flow.FlowWithContinueWith = (*Flow)(nil)
)
// flowDependencies are the dependencies NewFlow needs to construct a login
// flow: the configuration (for the lifespan and return-to validation), the
// clock, and the CSRF token generator.
type flowDependencies interface {
config.Provider
clock.Provider
nosurfx.CSRFTokenGeneratorProvider
}
func NewFlow(reg flowDependencies, r *http.Request, flowType flow.Type) (*Flow, error) {
conf := reg.Config()
now := reg.Clock().Now().UTC()
exp := conf.SelfServiceFlowLoginRequestLifespan(r.Context())
id := x.NewUUID()
requestURL := x.RequestURL(r).String()
// Pre-validate the return to URL which is contained in the HTTP request.
_, 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
}
hydraLoginChallenge, err := hydra.GetLoginChallengeID(conf, r)
if err != nil {
return nil, err
}
refresh, _ := strconv.ParseBool(r.URL.Query().Get("refresh"))
f := &Flow{
ID: id,
OAuth2LoginChallenge: hydraLoginChallenge,
ExpiresAt: now.Add(exp),
IssuedAt: now,
UI: &container.Container{
Method: "POST",
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
},
RequestURL: requestURL,
CSRFToken: reg.GenerateCSRFToken(r),
Type: flowType,
Refresh: refresh,
RequestedAAL: identity.AuthenticatorAssuranceLevel(strings.ToLower(cmp.Or(
r.URL.Query().Get("aal"),
string(identity.AuthenticatorAssuranceLevel1)))),
InternalContext: []byte("{}"),
State: flow.StateChooseMethod,
}
if err := flow.SetRequestBaseURL(f, x.BaseURLStringFromContext(r.Context())); err != nil {
return nil, err
}
return f, nil
}
func (f *Flow) GetType() flow.Type { return f.Type }
func (f *Flow) GetRequestURL() string { return f.RequestURL }
func (f *Flow) GetID() uuid.UUID { return f.ID }
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { f.InternalContext = bytes }
func (f *Flow) GetUI() *container.Container { return f.UI }
func (f *Flow) GetState() flow.State { return f.State }
func (Flow) GetFlowName() flow.FlowName { return flow.LoginFlow }
func (Flow) TableName() string { return "selfservice_login_flows" }
func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems }
func (f *Flow) SetReturnToVerification(to string) { f.ReturnToVerification = to }
func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge }
func (f *Flow) GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest { return f.HydraLoginRequest }
func (f *Flow) AppendTo(src *url.URL) *url.URL { return flow.AppendFlowTo(src, f.ID) }
func (f *Flow) SetState(state flow.State) { f.State = state }
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
// IsRefresh returns true if the login flow was triggered to re-authenticate the user.
// This is the case if the refresh query parameter is set to true.
func (f *Flow) IsRefresh() bool { return f.Refresh }
func (f *Flow) Valid(c clock.Clock) error {
if f.ExpiresAt.Before(c.Now()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
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()
// Populate the derived TestContext field for test-mode flows so the
// admin UI sees provider_id and (once captured) debug_payload.
if err := (&f).LoadTestContext(); err != nil {
return nil, err
}
return json.Marshal(local(f))
}
// UnmarshalJSON drops any incoming test_context. The field is derived from
// InternalContext and must only be set via LoadTestContext, otherwise a caller
// submitting a Flow blob could forge a debug payload.
func (f *Flow) UnmarshalJSON(data []byte) error {
type local Flow
if err := json.Unmarshal(data, (*local)(f)); err != nil {
return err
}
f.TestContext = nil
return nil
}
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) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []redir.SecureRedirectOption) {
return []redir.SecureRedirectOption{
redir.SecureRedirectReturnTo(f.ReturnTo),
redir.SecureRedirectUseSourceURL(f.RequestURL),
redir.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowLoginReturnTo(ctx, f.Active.String())),
}
}
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,
"refresh": f.Refresh,
"requested_aal": f.RequestedAAL,
}
}

View File

@@ -0,0 +1,283 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/x/clock"
"github.com/ory/x/jsonx"
"github.com/ory/x/sqlxx"
"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/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/x"
)
func TestFakeFlow(t *testing.T) {
t.Parallel()
var r login.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)
assert.NotNil(t, r.UI)
}
func TestNewFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
t.Run("captures request base URL from context at init", func(t *testing.T) {
// A proxy-aware middleware (cloud CaptureOriginalBaseURLMiddleware)
// stashes the validated customer-facing base URL on the request
// context at flow init. NewFlow must persist it on the flow so the
// OIDC/SAML provider submit can read it back.
req := (&http.Request{URL: &url.URL{Path: "/"}, Host: "slug.projects.oryapis.com"}).
WithContext(x.WithBaseURL(ctx, urlx.ParseOrPanic("http://localhost:4000")))
r, err := login.NewFlow(reg, req, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "http://localhost:4000", flow.GetRequestBaseURL(r))
// No captured base URL → nothing persisted (plain oryapis traffic).
r, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/"}, Host: "slug.projects.oryapis.com"}, flow.TypeBrowser)
require.NoError(t, err)
assert.Empty(t, flow.GetRequestBaseURL(r))
})
t.Run("type=aal", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "aal=aal2&refresh=true"}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.True(t, r.Refresh)
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, r.RequestedAAL)
r, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "refresh=true"}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.True(t, r.Refresh)
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, r.RequestedAAL)
})
t.Run("type=refresh", func(t *testing.T) {
t.Run("case=refresh accepts any truthy value", func(t *testing.T) {
parameters := []string{"true", "True", "1"}
for _, refresh := range parameters {
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: fmt.Sprintf("refresh=%v", refresh)}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.True(t, r.Refresh)
}
})
t.Run("case=refresh silently ignores invalid values", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "refresh=foo"}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.False(t, r.Refresh)
})
})
t.Run("type=return_to", func(t *testing.T) {
_, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, flow.TypeBrowser)
require.Error(t, err)
_, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
})
t.Run("type=browser", func(t *testing.T) {
t.Run("case=regular flow creation without a session", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/"),
Host: "ory.sh", TLS: &tls.ConnectionState{},
}, flow.TypeBrowser)
require.NoError(t, err)
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
assert.Equal(t, flow.TypeBrowser, r.Type)
assert.False(t, r.Refresh)
assert.Equal(t, "https://ory.sh/", r.RequestURL)
})
t.Run("case=regular flow creation", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("https://ory.sh/"),
Host: "ory.sh",
}, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "https://ory.sh/", r.RequestURL)
})
})
t.Run("type=api", func(t *testing.T) {
t.Run("case=flow with refresh", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/?refresh=true"),
Host: "ory.sh",
}, flow.TypeAPI)
require.NoError(t, err)
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
assert.Equal(t, flow.TypeAPI, r.Type)
assert.True(t, r.Refresh)
assert.Equal(t, "http://ory.sh/?refresh=true", r.RequestURL)
})
t.Run("case=flow without refresh", func(t *testing.T) {
r, err := login.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/"),
Host: "ory.sh",
}, flow.TypeAPI)
require.NoError(t, err)
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
assert.Equal(t, flow.TypeAPI, r.Type)
assert.False(t, r.Refresh)
assert.Equal(t, "http://ory.sh/", r.RequestURL)
})
})
t.Run("should parse login_challenge when Hydra is configured", func(t *testing.T) {
_, err := login.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=badee1"), Host: "ory.sh"}, flow.TypeBrowser)
require.Error(t, err)
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra")
r, err := login.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=8aadcb8fc1334186a84c4da9813356d9"), Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "8aadcb8fc1334186a84c4da9813356d9", string(r.OAuth2LoginChallenge))
})
}
func TestFlow(t *testing.T) {
t.Parallel()
r := &login.Flow{ID: x.NewUUID()}
assert.Equal(t, r.ID, r.GetID())
t.Run("case=expired", func(t *testing.T) {
for _, tc := range []struct {
r *login.Flow
valid bool
}{
{
r: &login.Flow{ExpiresAt: time.Now().Add(time.Hour), IssuedAt: time.Now().Add(-time.Minute)},
valid: true,
},
{r: &login.Flow{ExpiresAt: time.Now().Add(-time.Hour), IssuedAt: time.Now().Add(-time.Minute)}},
} {
if tc.valid {
require.NoError(t, tc.r.Valid(clock.New()))
} else {
require.Error(t, tc.r.Valid(clock.New()))
}
}
})
}
func TestGetType(t *testing.T) {
t.Parallel()
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=%s", ft), func(t *testing.T) {
r := &login.Flow{Type: ft}
assert.Equal(t, ft, r.GetType())
})
}
}
func TestGetRequestURL(t *testing.T) {
t.Parallel()
expectedURL := "http://foo/bar/baz"
f := &login.Flow{RequestURL: expectedURL}
assert.Equal(t, expectedURL, f.GetRequestURL())
}
func TestFlowEncodeJSON(t *testing.T) {
t.Parallel()
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
}
func TestFlowTestContextNotUnmarshalable(t *testing.T) {
t.Parallel()
// test_context is a derived field populated from internal_context during
// MarshalJSON. It must not be settable via incoming JSON, otherwise an
// attacker who can submit a Flow blob could forge a debug payload.
raw := []byte(`{
"id": "00000000-0000-0000-0000-000000000000",
"test_context": {
"provider_id": "forged",
"debug_payload": {"id_token_claims": {"sub": "forged"}}
}
}`)
var f login.Flow
require.NoError(t, json.Unmarshal(raw, &f))
assert.Nil(t, f.TestContext, "test_context must not be populated from JSON input")
}
func TestFlowDontOverrideReturnTo(t *testing.T) {
t.Parallel()
f := &login.Flow{ReturnTo: "/foo"}
f.SetReturnTo()
assert.Equal(t, "/foo", f.ReturnTo)
f = &login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
f.SetReturnTo()
assert.Equal(t, "/bar", f.ReturnTo)
}
func TestDuplicateCredentials(t *testing.T) {
t.Parallel()
t.Run("case=returns previous data", func(t *testing.T) {
t.Parallel()
f := new(login.Flow)
dc := flow.DuplicateCredentialsData{
CredentialsType: "foo",
CredentialsConfig: sqlxx.JSONRawMessage(`{"bar":"baz"}`),
DuplicateIdentifier: "bar",
}
require.NoError(t, flow.SetDuplicateCredentials(f, dc))
actual, err := flow.DuplicateCredentials(f)
require.NoError(t, err)
assert.Equal(t, dc, *actual)
})
t.Run("case=returns nil data", func(t *testing.T) {
t.Parallel()
f := new(login.Flow)
actual, err := flow.DuplicateCredentials(f)
require.NoError(t, err)
assert.Nil(t, actual)
})
}

View File

@@ -0,0 +1,999 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"net/http"
"strconv"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/sessiontokenexchange"
"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/events"
"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/otelx/semconv"
"github.com/ory/x/sqlxx"
"github.com/ory/x/stringsx"
"github.com/ory/x/urlx"
)
const (
RouteInitBrowserFlow = "/self-service/login/browser"
RouteInitAPIFlow = "/self-service/login/api"
RouteGetFlow = "/self-service/login/flows"
RouteSubmitFlow = "/self-service/login"
)
type (
dependencies interface {
clock.Provider
HookExecutorProvider
FlowPersistenceProvider
errorx.ManagementProvider
hydra.Provider
StrategyProvider
session.HandlerProvider
session.ManagementProvider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
otelx.Provider
config.Provider
ErrorHandlerProvider
sessiontokenexchange.PersistenceProvider
logrusx.Provider
TestStrategyProvider
}
HandlerProvider interface {
LoginHandler() *Handler
}
Handler struct{ d dependencies }
)
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
// The test-login-flow delete endpoint is authorized by the HMAC cookie
// set on the OIDC callback, not the global nosurf middleware.
h.d.CSRFHandler().IgnorePath(RouteDeleteTestFlow)
public.GET(RouteInitBrowserFlow, h.createBrowserLoginFlow)
public.GET(RouteInitAPIFlow, h.createNativeLoginFlow)
public.GET(RouteGetFlow, h.getLoginFlow)
public.POST(RouteSubmitFlow, h.updateLoginFlow)
public.GET(RouteSubmitFlow, h.updateLoginFlow)
public.DELETE(RouteDeleteTestFlow, h.deleteTestLoginFlow)
}
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))
admin.POST(RouteAdminCreateTestFlow, h.adminCreateTestLoginFlow)
}
type FlowOption func(f *Flow)
func WithFlowReturnTo(returnTo string) FlowOption {
return func(f *Flow) {
f.ReturnTo = returnTo
}
}
func WithOrganizationID(organizationID uuid.NullUUID) FlowOption {
return func(f *Flow) {
f.OrganizationID = organizationID
}
}
func WithInternalContext(internalContext []byte) FlowOption {
return func(f *Flow) {
f.InternalContext = internalContext
}
}
func WithFormErrorMessage(messages []text.Message) FlowOption {
return func(f *Flow) {
for i := range messages {
f.UI.Messages.Add(&messages[i])
}
}
}
func WithIsAccountLinking() FlowOption {
return func(f *Flow) {
f.isAccountLinkingFlow = true
}
}
func WithLoginChallenge(loginChallenge string) FlowOption {
return func(f *Flow) {
f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge)
}
}
func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, *session.Session, error) {
conf := h.d.Config()
f, err := NewFlow(h.d, r, ft)
if err != nil {
return nil, nil, err
}
for _, o := range opts {
o(f)
}
if f.RequestedAAL == "" {
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
}
switch cs := stringsx.SwitchExact(string(f.RequestedAAL)); {
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel1)):
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
identitySchema := ""
if requestedSchema := r.URL.Query().Get("identity_schema"); requestedSchema != "" {
identitySchema, err = conf.SelfServiceFlowIdentitySchema(r.Context(), requestedSchema)
if err != nil {
return nil, nil, err
}
}
f.IdentitySchema = flow.IdentitySchema(identitySchema)
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel2)):
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
default:
return nil, nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse AuthenticationMethod Assurance Level (AAL): %s", cs.ToUnknownCaseErr()))
}
// We assume an error means the user has no session.
// The full expansion is required: the strategies' form hydrators and the organization
// filter read the session's identity.
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
// No session exists yet
returnSessionTokenExchangeCode, _ := strconv.ParseBool(r.URL.Query().Get("return_session_token_exchange_code"))
if ft == flow.TypeAPI && returnSessionTokenExchangeCode {
e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID)
if err != nil {
return nil, nil, errors.WithStack(err)
}
f.SessionTokenExchangeCode = e.InitCode
}
// We can not request an AAL > 1 because we must first verify the first factor.
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
return nil, nil, errors.WithStack(ErrSessionRequiredForHigherAAL())
}
// We are setting refresh to false if no session exists.
f.Refresh = false
goto preLoginHook
} else if err != nil {
// Some other error happened - return that one.
return nil, nil, err
} else {
// A session exists already
if f.Refresh {
// We are refreshing so let's continue
goto preLoginHook
}
// We are not refreshing - so are we requesting MFA?
// If level is 1 we are not requesting AAL -> we are logged in already.
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 {
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
}
// We are requesting an assurance level which the session already has. So we are not upgrading the session
// in which case we want to return an error.
if f.RequestedAAL <= sess.AuthenticatorAssuranceLevel {
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
}
// Looks like we are requesting an AAL which is higher than what the session has.
goto preLoginHook
}
preLoginHook:
loginStrategies := h.d.LoginStrategies(r.Context(), PrepareOrganizations(r, f, sess)...)
for _, s := range loginStrategies {
var populateErr error
switch f.RequestedAAL {
case identity.AuthenticatorAssuranceLevel1:
switch s := s.(type) {
case AAL1FormHydrator:
switch {
case f.IsRefresh() && sess != nil:
// Refreshing takes precedence over identifier_first auth which can not be a refresh flow.
// Therefor this comes first.
populateErr = s.PopulateLoginMethodFirstFactorRefresh(r, f, sess)
case h.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(r.Context()) && !f.isAccountLinkingFlow:
populateErr = s.PopulateLoginMethodIdentifierFirstIdentification(r, f)
default:
populateErr = s.PopulateLoginMethodFirstFactor(r, f)
}
case AAL2FormHydrator:
continue
default:
populateErr = errors.WithStack(
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL1FormHydrator but did not."),
)
}
case identity.AuthenticatorAssuranceLevel2:
switch s := s.(type) {
case AAL2FormHydrator:
switch {
case f.IsRefresh():
// Refresh takes precedence.
populateErr = s.PopulateLoginMethodSecondFactorRefresh(r, f)
default:
populateErr = s.PopulateLoginMethodSecondFactor(r, f)
}
case AAL1FormHydrator:
continue
default:
populateErr = errors.WithStack(
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL2FormHydrator but did not."),
)
}
}
if populateErr != nil {
return nil, nil, populateErr
}
}
if f.Refresh {
f.UI.Messages.Set(text.NewInfoLoginReAuth())
}
if sess != nil && f.RequestedAAL > sess.AuthenticatorAssuranceLevel && f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
f.UI.Messages.Add(text.NewInfoLoginMFA())
}
if err := sortNodes(r.Context(), f.UI.Nodes); err != nil {
return nil, nil, err
}
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(h.d.GenerateCSRFToken(r))
}
if err := h.d.LoginHookExecutor().PreLoginHook(w, r, f); err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
return f, sess, nil
}
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
return nil, nil, err
}
// If the strategy supports fast login, we can attempt a fast path.
// We execute the fast login here because it requires the flow to be created first.
for _, s := range loginStrategies {
if strategy, ok := s.(FastLoginStrategy); ok {
if err := strategy.FastLogin2FA(w, r, f, sess); errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
span := trace.SpanFromContext(r.Context())
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
return nil, nil, err
} else if err != nil {
return nil, nil, err
}
break
}
}
span := trace.SpanFromContext(r.Context())
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
return f, nil, nil
}
func (h *Handler) FromOldFlow(w http.ResponseWriter, r *http.Request, of Flow) (*Flow, error) {
nf, _, err := h.NewLoginFlow(w, r, of.Type)
if err != nil {
return nil, err
}
nf.RequestURL = of.RequestURL
nf.IdentitySchema = of.IdentitySchema
return nf, nil
}
// Create Native Login Flow Parameters
//
// swagger:parameters createNativeLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createNativeLoginFlow struct {
// Refresh a login session
//
// If set to true, this will refresh an existing login session by
// asking the user to sign in again. This will reset the
// authenticated_at time of the session.
//
// in: query
Refresh bool `json:"refresh"`
// Request a Specific AuthenticationMethod Assurance Level
//
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
// you would set this to "aal2".
//
// in: query
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
// The Session Token of the Identity performing the settings flow.
//
// in: header
SessionToken string `json:"X-Session-Token"`
// EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token
// after the login flow has been completed.
//
// in: query
EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"`
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// An optional organization ID that should be used for logging this user in.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
//
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
// of MFA credentials to choose from to perform the second factor instead.
//
// in: query
Via string `json:"via"`
// An optional identity schema to use for the login flow.
//
// required: false
// in: query
IdentitySchema string `json:"identity_schema"`
}
// swagger:route GET /self-service/login/api frontend createNativeLoginFlow
//
// # Create Login Flow for Native Apps
//
// This endpoint initiates a login flow for native apps that do not use a browser, such as mobile devices, smart TVs, and so on.
//
// If a valid provided session cookie or session token is provided, a 400 Bad Request error
// will be returned unless the URL query parameter `?refresh=true` is set.
//
// To fetch an existing login flow call `/self-service/login/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, including CSRF login attacks.
//
// In the case of an error, the `error.id` of the JSON response body can be one of:
//
// - `session_already_available`: The user is already signed in.
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
//
// 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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: loginFlow
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createNativeLoginFlow(w http.ResponseWriter, r *http.Request) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createNativeLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
f, _, err := h.NewLoginFlow(w, r, flow.TypeAPI)
if err != nil {
if !errors.Is(err, flow.ErrCompletedByStrategy) {
h.d.Writer().WriteError(w, r, err)
}
return
}
h.d.Writer().Write(w, r, f)
}
// Initialize Browser Login Flow Parameters
//
// swagger:parameters createBrowserLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserLoginFlow struct {
// Refresh a login session
//
// If set to true, this will refresh an existing login session by
// asking the user to sign in again. This will reset the
// authenticated_at time of the session.
//
// in: query
Refresh bool `json:"refresh"`
// Request a Specific AuthenticationMethod Assurance Level
//
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
// you would set this to "aal2".
//
// in: query
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
// 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 Hydra login challenge. If present, Kratos will cooperate with
// Ory Hydra to act as an OAuth2 identity provider.
//
// The value for this parameter comes from `login_challenge` URL Query parameter sent to your
// application (e.g. `/login?login_challenge=abcde`).
//
// required: false
// in: query
HydraLoginChallenge string `json:"login_challenge"`
// An optional organization ID that should be used for logging this user in.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
//
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
// of MFA credentials to choose from to perform the second factor instead.
//
// in: query
Via string `json:"via"`
// An optional identity schema to use for the login flow.
//
// required: false
// in: query
IdentitySchema string `json:"identity_schema"`
}
// swagger:route GET /self-service/login/browser frontend createBrowserLoginFlow
//
// # Create Login Flow for Browsers
//
// This endpoint initializes a browser-based user login flow. This endpoint will set the appropriate
// cookies and anti-CSRF measures required for browser-based flows.
//
// If this endpoint is opened as a link in the browser, it will be redirected to
// `selfservice.flows.login.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
// exists already, the browser will be redirected to `urls.default_redirect_url` unless the query parameter
// `?refresh=true` was set.
//
// 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:
//
// - `session_already_available`: The user is already signed in.
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
//
// The optional query parameter login_challenge is set when using Kratos with
// Hydra in an OAuth2 flow. See the oauth2_provider.url configuration
// option.
//
// 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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: loginFlow
// 303: emptyResponse
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createBrowserLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
var (
hydraLoginRequest *hydraclientgo.OAuth2LoginRequest
hydraLoginChallenge sqlxx.NullString
)
if r.URL.Query().Has("login_challenge") {
var err error
hydraLoginChallenge, err = hydra.GetLoginChallengeID(h.d.Config(), r)
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(ctx, string(hydraLoginChallenge))
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
if !hydraLoginRequest.GetSkip() {
q := r.URL.Query()
q.Set("refresh", "true")
r.URL.RawQuery = q.Encode()
}
// on OAuth2 flows, we need to use the RequestURL
// as the ReturnTo URL.
// This is because a user might want to switch between
// different flows, such as login to registration and login to recovery.
// After completing a complex flow, such as recovery, we want the user
// to be redirected back to the original OAuth2 login flow.
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(ctx) {
// replace the return_to query parameter
q := r.URL.Query()
q.Set("return_to", hydraLoginRequest.RequestUrl)
r.URL.RawQuery = q.Encode()
}
}
a, sess, err := h.NewLoginFlow(w, r, flow.TypeBrowser)
if errors.Is(err, ErrAlreadyLoggedIn()) {
if hydraLoginRequest != nil {
if !hydraLoginRequest.GetSkip() {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, errors.WithStack(herodot.ErrForbidden().WithReason("ErrAlreadyLoggedIn indicated we can skip login, but Hydra asked us to refresh")))
return
}
rt, hydraErr := h.d.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(hydraLoginChallenge),
IdentityID: sess.IdentityID.String(),
SessionID: sess.ID.String(),
AuthenticationMethods: sess.AMR,
})
if hydraErr != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, hydraErr)
return
}
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, rt)
return
}
returnTo, redirErr := redir.SecureRedirectTo(r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(ctx)),
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
)
if redirErr != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, redirErr)
return
}
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
return
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
a.HydraLoginRequest = hydraLoginRequest
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), a, a.AppendTo(h.d.Config().SelfServiceFlowLoginUI(ctx)).String())
}
// Get Login Flow Parameters
//
// swagger:parameters getLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getLoginFlow struct {
// The Login Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/login?flow=abcde`).
//
// required: true
// in: query
ID string `json:"id"`
// 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/login/flows frontend getLoginFlow
//
// # Get Login Flow
//
// This endpoint returns a login flow's context with, for example, error details and other information.
//
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
//
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
// and you need to forward the incoming HTTP Cookie header to this endpoint:
//
// ```js
// // pseudo-code example
// router.get('/login', async function (req, res) {
// const flow = await client.getLoginFlow(req.header('cookie'), req.query['flow'])
//
// res.render('login', flow)
// })
// ```
//
// This request may fail due to several reasons. The `error.id` can be one of:
//
// - `session_already_available`: The user is already signed in.
// - `self_service_flow_expired`: The flow is expired and you should request a new one.
//
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: loginFlow
// 403: errorGeneric
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.getLoginFlow")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
ar, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, x.ParseUUID(r.URL.Query().Get("id")))
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
// Browser flows must include the CSRF token
//
// Resolves: https://github.com/ory/kratos/issues/1282
//
// Test flows have their own scheme:
// - choose_method (uncaptured): public read. The flow carries no PII
// and was admin-created with a server-minted token that the browser
// never received, so a CSRF check would always fail.
// - captured: require the CSRF cookie to match f.CSRFToken. The OIDC
// callback regenerated the cookie+token pair, so only the browser
// that completed the round trip can read the captured DebugPayload.
switch {
case ar.IsTest():
// TestContext is a derived field (loaded from internal_context); the
// SQL fetch above doesn't populate it.
if err := ar.LoadTestContext(); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if ar.TestContext != nil && ar.TestContext.DebugPayload != nil &&
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken) {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
return
}
case ar.Type == flow.TypeBrowser && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken):
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
return
}
if ar.ExpiresAt.Before(h.d.Clock().Now()) {
if ar.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(ctx, h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Redirect the user to the login flow init endpoint to initialize a new login flow.").
WithDetail("redirect_to", redirectURL.String()).
WithDetail("return_to", ar.ReturnTo)))
return
}
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Call the login flow init API endpoint to initialize a new login flow.").
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), RouteInitAPIFlow).String())))
return
}
if ar.OAuth2LoginChallenge != "" {
hlr, err := h.d.Hydra().GetLoginRequest(ctx, string(ar.OAuth2LoginChallenge))
if err != nil {
// We don't redirect back to the third party on errors because Hydra doesn't
// give us the 3rd party return_uri when it redirects to the login UI.
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
ar.HydraLoginRequest = hlr
}
h.d.Writer().Write(w, r, ar)
}
// Update Login Flow Parameters
//
// swagger:parameters updateLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateLoginFlow struct {
// The Login Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/login?flow=abcde`).
//
// required: true
// in: query
Flow string `json:"flow"`
// in: body
// required: true
Body updateLoginFlowBody
// 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"`
}
// swagger:model updateLoginFlowBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateLoginFlowBody struct{}
// swagger:route POST /self-service/login frontend updateLoginFlow
//
// # Submit a Login Flow
//
// Use this endpoint to complete a login flow. This endpoint
// behaves differently for API and browser flows.
//
// API flows expect `application/json` to be sent in the body and responds with
// - HTTP 200 and a application/json body with the session token on success;
// - HTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;
// - HTTP 400 on form validation errors.
//
// Browser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with
// - a HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;
// - a HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.
//
// Browser flows with an accept header of `application/json` will not redirect but instead 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 400 on form validation errors.
//
// If this endpoint is called with `Accept: application/json` in the 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_already_available`: The user is already signed in.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Schemes: http, https
//
// Consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Header:
// - Set-Cookie
//
// Responses:
// 200: successfulNativeLogin
// 303: emptyResponse
// 400: loginFlow
// 410: errorGeneric
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-low
func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request) {
var err error
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.updateLoginFlow")
ctx = semconv.ContextWithAttributes(ctx,
attribute.String(events.AttributeKeySelfServiceStrategyUsed.String(), "login"),
attribute.String(events.AttributeKeySelfServiceFlowName.String(), "login"),
)
r = r.WithContext(ctx)
defer otelx.End(span, &err)
rid, err := flow.GetFlowID(r)
if err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, nil, "", node.DefaultGroup, err)
return
}
f, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, rid)
if err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
return
}
// The full expansion is required: the session is handed to every login strategy below.
sess, err := h.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandEverything, identity.ExpandEverything)
if err == nil {
if f.Refresh {
// If we want to refresh, continue the login
goto continueLogin
}
if f.RequestedAAL > sess.AuthenticatorAssuranceLevel {
// If we want to upgrade AAL, continue the login
goto continueLogin
}
if x.IsJSONRequest(r) || f.Type == flow.TypeAPI {
// We are not upgrading AAL, nor are we refreshing. Error!
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrAlreadyLoggedIn()))
return
}
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx).String(), http.StatusSeeOther)
return
} else if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
// Only failure scenario here is if we try to upgrade the session to a higher AAL without actually
// having a session.
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrSessionRequiredForHigherAAL()))
return
}
sess = session.NewInactiveSession()
} else {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
return
}
continueLogin:
if err := f.Valid(h.d.Clock()); err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
return
}
var ct identity.CredentialsType
var i *identity.Identity
var group node.UiNodeGroup
for _, ss := range h.d.AllLoginStrategies() {
interim, err := ss.Login(w, r, f, sess)
group = ss.NodeGroup()
if errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, err)
return
}
// What can happen is that we re-authenticate as another user. In this case, we need to use a completely fresh
// session!
if sess.IdentityID != uuid.Nil && sess.IdentityID != interim.ID {
sess = session.NewInactiveSession()
}
method := ss.CompletedAuthenticationMethod(ctx)
sess.CompletedLoginForMethod(method)
i = interim
ct = ss.ID()
break
}
if i == nil {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewNoLoginStrategyResponsible()))
return
}
if err := h.d.LoginHookExecutor().PostLoginHook(w, r, group, f, i, sess, ""); err != nil {
if errors.Is(err, ErrAddressNotVerified()) {
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewAddressNotVerifiedError()))
return
}
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, err)
return
}
}

View File

@@ -0,0 +1,994 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/gobuffalo/httptest"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
stdtotp "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/clock"
"github.com/ory/x/httprouterx"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/selfservice/strategy/totp"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func init() {
corpx.RegisterFakes()
}
func TestFlowLifecycle(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
fakeHydra := hydra.NewFake()
reg.SetHydra(fakeHydra)
routerPublic := httprouterx.NewRouterPublic()
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerPublic, httprouterx.NewRouterAdminWithPrefix())
loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
returnToTS := testhelpers.NewRedirTS(t, "return_to", conf)
errorTS := testhelpers.NewErrorTestServer(t, reg)
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "default", URL: "file://./stub/password.schema.json"},
{ID: "email", URL: "file://./stub/email.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://./stub/phone.schema.json", SelfserviceSelectable: true},
{ID: "not-allowed", URL: "file://./stub/password.schema.json"},
})
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
assertion := func(body []byte, isForced, isApi bool) {
r := gjson.GetBytes(body, "refresh")
assert.True(t, r.Exists(), "%s", body)
assert.Equal(t, isForced, r.Bool(), "%s", body)
if isApi {
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
} else {
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
}
}
initAuthenticatedFlow := func(t *testing.T, extQuery url.Values, isAPI bool) (*http.Response, []byte) {
route := login.RouteInitBrowserFlow
if isAPI {
route = login.RouteInitAPIFlow
}
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+route, nil)
req.URL.RawQuery = extQuery.Encode()
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
if isAPI {
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
}
return res, body
}
initUnauthenticatedFlow := func(t *testing.T, extQuery url.Values, isAPI bool) (*http.Response, []byte) {
route := login.RouteInitBrowserFlow
if isAPI {
route = login.RouteInitAPIFlow
}
client := ts.Client()
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+route, nil)
req.URL.RawQuery = extQuery.Encode()
res, err := client.Do(req)
require.NoError(t, errors.WithStack(err))
body, err := io.ReadAll(res.Body)
require.NoError(t, errors.WithStack(err))
require.NoError(t, res.Body.Close())
return res, body
}
initFlowWithAccept := func(t *testing.T, query url.Values, isAPI bool, accept string) (*http.Response, []byte) {
route := login.RouteInitBrowserFlow
if isAPI {
route = login.RouteInitAPIFlow
}
c := ts.Client()
req, err := http.NewRequest("GET", ts.URL+route+"?"+query.Encode(), nil)
require.NoError(t, err)
if accept != "" {
req.Header.Set("Accept", accept)
}
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
initFlow := func(t *testing.T, query url.Values, isAPI bool) (*http.Response, []byte) {
return initFlowWithAccept(t, query, isAPI, "")
}
initSPAFlow := func(t *testing.T, query url.Values) (*http.Response, []byte) {
return initFlowWithAccept(t, query, false, "application/json")
}
id1mail, id2mail := x.NewUUID().String(), x.NewUUID().String()
identity1 := &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{id1mail},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), // foobar
},
},
State: identity.StateActive,
Traits: identity.Traits(`{"username":"` + id1mail + `"}`),
}
identity2 := &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{id2mail},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), // foobar
},
},
State: identity.StateActive,
Traits: identity.Traits(`{"username":"` + id2mail + `"}`),
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), identity1))
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), identity2))
t.Run("lifecycle=submit", func(t *testing.T) {
t.Run("interaction=unauthenticated", func(t *testing.T) {
run := func(t *testing.T, tt flow.Type, aal string, values url.Values) (string, *http.Response) {
f := login.Flow{
Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(),
UI: container.New(""), Refresh: false, RequestedAAL: identity.AuthenticatorAssuranceLevel(aal),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
res, err := testhelpers.NewTestClient(t).PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), values)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
return string(body), res
}
t.Run("case=ensure aal can only be upgraded with a session on submit", func(t *testing.T) {
t.Run("type=api", func(t *testing.T) {
body, res := run(t, flow.TypeAPI, "aal2", url.Values{"method": {"password"}})
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.Get(body, "error.reason").String())
})
t.Run("type=browser", func(t *testing.T) {
body, res := run(t, flow.TypeBrowser, "aal2", url.Values{"method": {"password"}})
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.Get(body, "reason").String())
})
})
t.Run("case=end up with method missing when aal is ok", func(t *testing.T) {
t.Run("type=api", func(t *testing.T) {
body, res := run(t, flow.TypeAPI, "aal1", url.Values{"method": {"not-exist"}})
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
assertx.EqualAsJSON(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), body)
})
t.Run("type=browser", func(t *testing.T) {
body, res := run(t, flow.TypeBrowser, "aal1", url.Values{"method": {"not-exist"}})
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
assertx.EqualAsJSON(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), body)
})
})
t.Run("case=reset the session when refresh is true but identity is different", func(t *testing.T) {
testhelpers.NewRedirSessionEchoTS(t, reg)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
})
run := func(t *testing.T, tt flow.Type) (string, string) {
f := login.Flow{Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: false, RequestedAAL: "aal1"}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
hc := testhelpers.NewClientWithCookies(t)
res, err := hc.PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
require.NoError(t, err)
firstSession := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
f = login.Flow{Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: true, RequestedAAL: "aal1"}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
vv := testhelpers.EncodeFormAsJSON(t, tt == flow.TypeAPI, url.Values{"method": {"password"}, "password_identifier": {id2mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
req, err := http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(vv))
require.NoError(t, err)
if tt == flow.TypeAPI {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+gjson.GetBytes(firstSession, "session_token").String())
} else {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
res, err = hc.Do(req)
require.NoError(t, err)
secondSession := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
return string(firstSession), string(secondSession)
}
t.Run("type=browser", func(t *testing.T) {
a, b := run(t, flow.TypeBrowser)
assert.Equal(t, id1mail, gjson.Get(a, "identity.traits.username").String())
assert.Equal(t, id2mail, gjson.Get(b, "identity.traits.username").String())
assert.NotEmpty(t, gjson.Get(b, "id").String())
assert.NotEqual(t, gjson.Get(b, "id").String(), gjson.Get(a, "id").String())
})
t.Run("type=api", func(t *testing.T) {
a, b := run(t, flow.TypeAPI)
assert.Equal(t, id1mail, gjson.Get(a, "session.identity.traits.username").String())
assert.Equal(t, id2mail, gjson.Get(b, "session.identity.traits.username").String())
assert.NotEmpty(t, gjson.Get(a, "session_token").String())
assert.NotEqual(t, gjson.Get(a, "session_token").String(), gjson.Get(b, "session_token").String())
assert.NotEmpty(t, gjson.Get(b, "session.id").String())
assert.NotEqual(t, gjson.Get(b, "session.id").String(), gjson.Get(a, "id").String())
})
})
t.Run("case=changed kratos session identifiers when refresh is true", func(t *testing.T) {
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
})
t.Run("type=browser", func(t *testing.T) {
// Setup flow
f := login.Flow{Type: flow.TypeBrowser, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: false, RequestedAAL: "aal1"}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
// Submit Login
hc := testhelpers.NewClientWithCookies(t)
res, err := hc.PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
require.NoError(t, err)
// Check response and session cookie presence
assert.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL))), "ory_kratos_session")
cookies1 := hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL + login.RouteGetFlow))
req, err := http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
require.NoError(t, err)
res, err = hc.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
firstSession := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
// Refresh
f = login.Flow{Type: flow.TypeBrowser, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: true, RequestedAAL: "aal1"}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
vv := testhelpers.EncodeFormAsJSON(t, false, url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
req, err = http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(vv))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Submit Login
res, err = hc.Do(req)
require.NoError(t, err)
// Check response and session cookie presence
assert.Equal(t, http.StatusOK, res.StatusCode)
require.Len(t, hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL+login.RouteGetFlow)), 1)
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL))), "ory_kratos_session")
cookies2 := hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL + login.RouteGetFlow))
req, err = http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
require.NoError(t, err)
res, err = hc.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
secondSession := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
// Sessions should still be resolvable despite different kratos session identifier due to nonce
assert.NotEqual(t, cookies1[0].String(), cookies2[0].String())
assert.Equal(t, id1mail, gjson.Get(string(firstSession), "identity.traits.username").String())
assert.Equal(t, id1mail, gjson.Get(string(secondSession), "identity.traits.username").String())
assert.Equal(t, gjson.Get(string(secondSession), "id").String(), gjson.Get(string(firstSession), "id").String())
})
})
})
t.Run("case=ensure aal is checked for upgradeability on session", func(t *testing.T) {
run := func(t *testing.T, tt flow.Type, values url.Values) (string, *http.Response) {
f := login.Flow{
Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(),
UI: container.New(""), Refresh: false, RequestedAAL: "aal1",
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
req, err := http.NewRequest("GET", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(values.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
return string(body), res
}
t.Run("type=api", func(t *testing.T) {
body, res := run(t, flow.TypeAPI, url.Values{"method": {"password"}})
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn().Reason(), gjson.Get(body, "ui.messages.0.text").String(), body)
})
t.Run("type=browser", func(t *testing.T) {
_, res := run(t, flow.TypeBrowser, url.Values{"method": {"password"}})
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
})
t.Run("case=should return an error because the request is expired", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, "50ms")
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, "10m")
})
// Freeze the clock so the handler and the expected error read the same instant. The "expired N minutes
// ago" reason is then deterministic instead of depending on the request round-trip time.
now := time.Now().UTC()
reg.SetClock(clock.NewMock(now))
t.Cleanup(func() { reg.SetClock(clock.New()) })
expired := now.Add(-time.Minute)
run := func(t *testing.T, tt flow.Type, aal string, values string, isSPA bool) (string, *http.Response) {
f := login.Flow{
Type: tt, ExpiresAt: expired, IssuedAt: now,
UI: container.New(""), Refresh: false, RequestedAAL: identity.AuthenticatorAssuranceLevel(aal),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
req, err := http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(values))
require.NoError(t, err)
if isSPA {
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
} else {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
res, err := testhelpers.NewTestClient(t).Do(req)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
return string(body), res
}
t.Run("type=api", func(t *testing.T) {
actual, res := run(t, flow.TypeAPI, "aal1", `{"method":"password"}`, false)
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expired), json.RawMessage(actual), []string{"use_flow_id", "since"}, "expired", "%s", actual)
})
t.Run("type=browser", func(t *testing.T) {
body, res := run(t, flow.TypeBrowser, "aal1", url.Values{"method": {"password"}}.Encode(), false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "expired", "%s", body)
})
t.Run("type=SPA", func(t *testing.T) {
actual, res := run(t, flow.TypeBrowser, "aal1", `{"method":"password"}`, true)
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expired), json.RawMessage(actual), []string{"use_flow_id", "since"}, "expired", "%s", actual)
})
})
t.Run("case=should return to settings flow after successful mfa login after recovery", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeTOTP.String(), true)
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToTS.URL})
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, string(identity.AuthenticatorAssuranceLevel1))
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, string(identity.AuthenticatorAssuranceLevel1))
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeTOTP.String(), false)
})
key, err := totp.NewKey(context.Background(), "foo", reg)
require.NoError(t, err)
email := testhelpers.RandomEmail()
id := &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {
Type: "password",
Identifiers: []string{email},
Config: sqlxx.JSONRawMessage(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
},
},
Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
require.NoError(t, reg.IdentityManager().CreateIdentities(context.Background(), []*identity.Identity{id}, identity.ManagerAllowWriteProtectedTraits))
id.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{
Type: identity.CredentialsTypeTOTP,
Identifiers: []string{id.ID.String()},
Config: sqlxx.JSONRawMessage(`{"totp_url":"` + string(key.URL()) + `"}`),
})
require.NoError(t, reg.IdentityManager().Update(context.Background(), id, identity.ManagerAllowWriteProtectedTraits))
h := func(w http.ResponseWriter, r *http.Request) {
sess, err := testhelpers.NewActiveSession(r, reg, id, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
sess.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
require.NoError(t, reg.SessionManager().IssueCookie(context.Background(), w, r, sess))
require.Equal(t, identity.AuthenticatorAssuranceLevel1, sess.AuthenticatorAssuranceLevel)
}
routerPublic.GET("/mock-session", h)
client := testhelpers.NewClientWithCookies(t)
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/mock-session")
settingsURL := ts.URL + settings.RouteInitBrowserFlow + "?return_to=" + url.QueryEscape(returnToTS.URL)
req, err := http.NewRequest("GET", settingsURL, nil)
require.NoError(t, err)
// we initialize the settings flow with a session that has AAL1 set
resp, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// we expect the request to redirect to the login flow because the AAL1 session is not sufficient
requestURL, err := url.Parse(resp.Request.Referer())
require.NoError(t, err)
require.Equal(t, login.RouteInitBrowserFlow, requestURL.Path)
require.Equal(t, "aal2", requestURL.Query().Get("aal"))
require.Equal(t, settingsURL, requestURL.Query().Get("return_to"))
// we expect to be on the login page now
respURL := resp.Request.URL
require.NoError(t, err)
require.Equal(t, "/login-ts", respURL.Path)
flowID := respURL.Query().Get("flow")
require.NotEmpty(t, flowID)
code, err := stdtotp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
req, err = http.NewRequest("GET", ts.URL+login.RouteGetFlow+"?id="+flowID, nil)
require.NoError(t, err)
req.Header.Add("Content-Type", "application/json")
resp, err = client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
body := string(x.MustReadAll(resp.Body))
defer func() { _ = resp.Body.Close() }()
totpNode := gjson.Get(body, "ui.nodes.#(attributes.name==totp_code)").String()
require.NotEmpty(t, totpNode)
require.NotEmpty(t, gjson.Get(body, "ui.action").String())
csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
req, err = http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+flowID, strings.NewReader(url.Values{
"method": {"totp"},
"totp_code": {code},
"csrf_token": {csrfToken},
}.Encode()))
require.NoError(t, err)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err = client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
location, err := resp.Location()
require.NoError(t, err)
require.Equal(t, settings.RouteInitBrowserFlow, location.Path)
})
})
t.Run("lifecycle=init", func(t *testing.T) {
t.Run("suite=identity schema in query", func(t *testing.T) {
for _, tc := range []struct {
name string
query url.Values
wantErr bool
wantIdentifier string
}{{
name: "not-allowed",
query: url.Values{"identity_schema": {"not-allowed"}},
wantErr: true,
}, {
name: "not-found",
query: url.Values{"identity_schema": {"not-found"}},
wantErr: true,
}, {
name: "phone",
query: url.Values{"identity_schema": {"phone"}},
wantIdentifier: "Phone Number",
}, {
name: "email",
query: url.Values{"identity_schema": {"email"}},
wantIdentifier: "E-Mail Address",
}, {
name: "default",
query: url.Values{"identity_schema": {"default"}},
wantIdentifier: "Username",
}} {
t.Run("case="+tc.name, func(t *testing.T) {
t.Run("flow=api", func(t *testing.T) {
res, body := initFlow(t, tc.query, true)
if tc.wantErr {
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
return
}
assert.Equalf(t, tc.wantIdentifier, gjson.GetBytes(body, "ui.nodes.#(attributes.name==identifier).meta.label.text").String(), "%s", body)
})
t.Run("flow=browser", func(t *testing.T) {
res, body := initFlow(t, tc.query, false)
if tc.wantErr {
require.Contains(t, res.Request.URL.String(), errorTS.URL, "%s", body)
assert.EqualValues(t, "Bad Request", gjson.GetBytes(body, "status").String(), "%s", body)
return
}
assert.Equalf(t, tc.wantIdentifier, gjson.GetBytes(body, "ui.nodes.#(attributes.name==identifier).meta.label.context.title").String(), "%s", body)
})
})
}
})
t.Run("flow=api", func(t *testing.T) {
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertion(body, false, true)
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=returns session exchange code with any truthy value", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToTS.URL, "https://example.com"})
parameters := []string{"true", "True", "1"}
for _, param := range parameters {
t.Run("return_session_token_exchange_code="+param, func(t *testing.T) {
res, body := initFlow(t, url.Values{
"return_session_token_exchange_code": {param},
"return_to": {"https://example.com/redirect"},
}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
assert.Equal(t, "https://example.com/redirect", gjson.GetBytes(body, "return_to").String())
})
}
})
t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "error.reason").String())
})
t.Run("case=can request refresh and aal at the same time on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "Please confirm this action by verifying that it is you.", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
assertx.EqualAsJSON(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.1.text").String(), "%s", body)
})
t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "error.reason").String())
})
t.Run("case=ignores aal1 if session has aal1 already", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal1"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?", gjson.GetBytes(body, "error.reason").String())
})
t.Run("case=aal0 is not a valid value", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "error.reason").String())
})
t.Run("case=indicates two factor auth", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginMFA().Text)
})
t.Run("case=does not set forced flag on unauthenticated request with refresh=true", func(t *testing.T) {
res, body := initFlow(t, url.Values{"refresh": {"true"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertion(body, false, true)
})
t.Run("case=does not set forced flag on authenticated request without refresh=true", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{}, true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
t.Run("case=does not set forced flag on authenticated request with refresh=false", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"false"}}, true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
t.Run("case=does set forced flag on authenticated request with refresh=true", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertion(body, true, true)
})
t.Run("case=check info message on authenticated request with refresh=true", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertion(body, true, true)
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginReAuth().Text)
})
})
t.Run("flow=browser", func(t *testing.T) {
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{}, false)
assertion(body, false, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
})
t.Run("case=never returns a session token exchange code", func(t *testing.T) {
_, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), false)
assertion(body, false, false)
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("case=can request refresh and aal at the same time on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
assertx.EqualAsJSON(t, "Please confirm this action by verifying that it is you.", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
assertx.EqualAsJSON(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.1.text").String(), "%s", body)
})
t.Run("case=redirects if aal2 is requested and set up already without refresh", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}, "set_aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "reason").String())
})
t.Run("case=ignores aal1 if session has aal1 already", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, url.Values{"aal": {"aal1"}}, false)
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
t.Run("case=aal0 is not a valid value", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "reason").String())
})
t.Run("case=indicates two factor auth", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginMFA().Text)
})
t.Run("case=makes request with JSON", func(t *testing.T) {
res, body := initSPAFlow(t, url.Values{})
assertion(body, false, false)
assert.NotContains(t, res.Request.URL.String(), loginTS.URL)
})
t.Run("case=does not set forced flag on unauthenticated request with refresh=true", func(t *testing.T) {
res, body := initFlow(t, url.Values{"refresh": {"true"}}, false)
assertion(body, false, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
})
t.Run("case=does not set forced flag on authenticated request without refresh=true", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, url.Values{}, false)
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
t.Run("case=does not set forced flag on authenticated request with refresh=false", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, url.Values{"refresh": {"false"}}, false)
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
t.Run("case=does set forced flag on authenticated request with refresh=true", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, false)
assertion(body, true, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
})
t.Run("case=redirects with 303", func(t *testing.T) {
c := &http.Client{}
// don't get the reference, instead copy the values, so we don't alter the client directly.
*c = *ts.Client()
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", ts.URL+login.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)
})
t.Run("case=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}}, false)
require.Contains(t, res.Request.URL.String(), errorTS.URL)
require.Contains(t, string(body), "refusing to parse")
})
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
t.Run("case=oauth2 flow init should override return_to to the oauth2 request_url", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.com", "https://example.com"})
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderOverrideReturnTo, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderOverrideReturnTo, false)
})
res, _ := initUnauthenticatedFlow(t, url.Values{
"return_to": {"https://example.com"},
"login_challenge": {hydra.FakeValidLoginChallenge},
}, false)
require.Equal(t, http.StatusOK, res.StatusCode)
require.Contains(t, res.Request.URL.String(), loginTS.URL)
c := ts.Client()
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+login.RouteGetFlow, nil)
req.URL.RawQuery = url.Values{"id": {res.Request.URL.Query().Get("flow")}}.Encode()
res, err := c.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, errors.WithStack(err))
require.NoError(t, res.Body.Close())
assert.Equal(t, "https://www.ory.com", gjson.GetBytes(body, "return_to").Value())
})
t.Run("case=invalid oauth2 login challenge returns 400 Bad Request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeInvalidLoginChallenge}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assert.Equal(t, int64(http.StatusBadRequest), gjson.GetBytes(body, "code").Int())
assert.Contains(t, gjson.GetBytes(body, "reason").String(), "Unable to get OAuth 2.0 Login Challenge")
})
t.Run("case=oauth2 flow init succeeds", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}}, false)
require.Contains(t, res.Request.URL.String(), loginTS.URL)
})
t.Run("case=oauth2 flow init adds oauth2_login_request field", func(t *testing.T) {
res, body := initSPAFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}})
assert.NotContains(t, res.Request.URL.String(), loginTS.URL)
assert.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body)
})
t.Run("case=oauth2 flow with existing session and JSON request should not return null", func(t *testing.T) {
// This test reproduces issue #10255 where the /self-service/login/browser endpoint
// returns null when called with an existing session, a Hydra login challenge, and
// an Accept: application/json header.
// Has a side effect on this test suite but since its running serial and there is no significantly easier way it's acceptable.
fakeHydra.Skip = true
t.Cleanup(func() {
fakeHydra.Skip = false
})
// Make a login request with an authenticated session, Hydra login challenge, and JSON accept
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+login.RouteInitBrowserFlow+"?login_challenge="+hydra.FakeValidLoginChallenge, nil)
req.Header.Set("Accept", "application/json")
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
// Before the fix, this would return 200 OK with "null" as body because of variable shadowing
// After the fix, it should return a proper error response with ErrAlreadyLoggedIn
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "Response should be 400 Bad Request, got body: %s", body)
assert.NotEqual(t, "null", string(body), "Response should not be null")
assert.NotEmpty(t, gjson.GetBytes(body, "error.id").String(), "Should have error.id field, got body: %s", body)
assert.Equal(t, "session_already_available", gjson.GetBytes(body, "error.id").String(), "Should return session_already_available error, got body: %s", body)
assert.Contains(t, gjson.GetBytes(body, "error.reason").String(), "A valid session was detected", "Should have proper error reason, got body: %s", body)
})
})
t.Run("case=relative redirect when self-service login ui is a relative URL", func(t *testing.T) {
reg.Config().MustSet(ctx, config.ViperKeySelfServiceLoginUI, "/login-ts")
assert.Regexp(
t,
"^/login-ts.*$",
testhelpers.GetSelfServiceRedirectLocation(t, ts.URL+login.RouteInitBrowserFlow),
)
})
})
}
func TestGetFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
returnToTS := testhelpers.NewRedirTS(t, "", conf)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/password.schema.json")
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "default", URL: "file://./stub/password.schema.json"},
{ID: "email", URL: "file://./stub/email.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://./stub/phone.schema.json", SelfserviceSelectable: true},
{ID: "not-allowed", URL: "file://./stub/password.schema.json"},
})
setupLoginUI := func(t *testing.T, c *http.Client) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// It is important that we use a HTTP request to fetch the flow because that will show us if CSRF works or not
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+login.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
require.NoError(t, err)
}))
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, ts.URL)
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
t.Cleanup(ts.Close)
return ts
}
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{
"enabled": true,
})
t.Run("case=fetching successful", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupLoginUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
assert.NotEmpty(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body)
assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), gjson.GetBytes(body, "id").String(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), public.URL, "%s", body)
})
t.Run("case=csrf cookie missing", func(t *testing.T) {
client := testhelpers.NewTestClient(t)
setupLoginUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=expired", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupLoginUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
// Expire the flow
f, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), f))
// Try the flow but it is expired
res, body := testhelpers.EasyGet(t, client, public.URL+login.RouteGetFlow+"?id="+f.ID.String())
assert.EqualValues(t, http.StatusGone, res.StatusCode)
assert.Equal(t, public.URL+login.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
})
t.Run("case=expired with return_to and schema_id", func(t *testing.T) {
returnTo := returnToTS.URL
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
client := testhelpers.NewClientWithCookies(t)
setupLoginUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow+"?return_to="+returnTo+"&identity_schema=email")
// Expire the flow
f, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(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", public.URL, login.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 := public.URL + login.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := client.PostForm(u, url.Values{"password_identifier": {"email@ory.sh"}, "csrf_token": {f.CSRFToken}, "password": {"password"}, "method": {"password"}})
require.NoError(t, err)
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
f, err = reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
require.NoError(t, err)
assert.Equal(t, public.URL+login.RouteInitBrowserFlow+"?return_to="+returnTo+"&identity_schema=email", f.RequestURL)
})
t.Run("case=not found", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupLoginUI(t, client)
res, _ := testhelpers.EasyGet(t, client, public.URL+login.RouteGetFlow+"?id="+x.NewUUID().String())
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
})
}

View File

@@ -0,0 +1,464 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
type (
PreHookExecutor interface {
ExecuteLoginPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
}
PostHookExecutor interface {
ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, a *Flow, s *session.Session) error
}
HooksProvider interface {
PreLoginHooks(ctx context.Context) ([]PreHookExecutor, error)
PostLoginHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]PostHookExecutor, error)
}
)
type (
executorDependencies interface {
config.Provider
hydra.Provider
identity.PrivilegedPoolProvider
identity.ManagementProvider
session.ManagementProvider
session.PersistenceProvider
nosurfx.CSRFTokenGeneratorProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
sessiontokenexchange.PersistenceProvider
HandlerProvider
FlowPersistenceProvider
HooksProvider
StrategyProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
LoginHookExecutor() *HookExecutor
}
)
func PostHookExecutorNames(e []PostHookExecutor) []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}
}
func (e *HookExecutor) checkAAL(ctx context.Context, s *session.Session, a *Flow) error {
err := e.d.SessionManager().DoesSessionSatisfy(ctx, s, e.d.Config().SessionWhoAmIAAL(ctx))
if err == nil {
return nil
}
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
if a != nil && aalErr.PassReturnToAndLoginChallengeParameters(a.RequestURL) != nil {
_ = aalErr.WithDetail("pass_request_params_error", "failed to pass request parameters to aalErr.RedirectTo")
}
return aalErr
}
return err
}
func (e *HookExecutor) handleLoginError(_ http.ResponseWriter, r *http.Request, g node.UiNodeGroup, f *Flow, i *identity.Identity, flowError error) error {
if f != nil {
if i != nil {
cont, err := container.NewFromStruct("", g, i.Traits, "traits")
if err != nil {
e.d.Logger().WithError(err).Warn("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) PostLoginHook(
w http.ResponseWriter,
r *http.Request,
g node.UiNodeGroup,
f *Flow,
i *identity.Identity,
s *session.Session,
provider string,
) (err error) {
ctx := r.Context()
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostLoginHook")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
// We need to set the identity here because we check the available AAL in maybeLinkCredentials.
s.IdentityID = i.ID
s.Identity = i
if err := e.maybeLinkCredentials(ctx, s, i, f); err != nil {
return err
}
if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil {
return err
}
c := e.d.Config()
// Verify the redirect URL before we do any other processing.
returnTo, err := redir.SecureRedirectTo(r,
c.SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectReturnTo(f.ReturnTo),
redir.SecureRedirectUseSourceURL(f.RequestURL),
redir.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowLoginReturnTo(ctx, f.Active.String())),
)
if err != nil {
return err
}
span.SetAttributes(otelx.StringAttrs(map[string]string{
"return_to": returnTo.String(),
"flow_type": string(flow.TypeBrowser),
"redirect_reason": "login successful",
})...)
if f.Type == flow.TypeBrowser && x.IsJSONRequest(r) {
f.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String()))
}
classified := s
s = s.Declassified()
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", f.Active).
Debug("Running ExecuteLoginPostHook.")
hooks, err := e.d.PostLoginHooks(ctx, f.Active)
if err != nil {
return err
}
for k, executor := range hooks {
if err := executor.ExecuteLoginPostHook(w, r, g, f, s); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookExecutorNames(hooks)).
WithField("identity_id", i.ID).
WithField("flow_method", f.Active).
Debug("A ExecuteLoginPostHook hook aborted early.")
span.SetAttributes(attribute.String("redirect_reason", "aborted by hook"), attribute.String("executor", fmt.Sprintf("%T", executor)))
return nil
}
return e.handleLoginError(w, r, g, f, i, err)
}
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookExecutorNames(hooks)).
WithField("identity_id", i.ID).
WithField("flow_method", f.Active).
Debug("ExecuteLoginPostHook completed successfully.")
}
if f.Type == flow.TypeAPI {
span.SetAttributes(attribute.String("flow_type", string(flow.TypeAPI)))
if err := e.d.SessionPersister().UpsertSession(ctx, s); err != nil {
return errors.WithStack(err)
}
e.d.Logger().
WithRequest(r).
WithField("session_id", s.ID).
WithField("identity_id", i.ID).
Info("Identity authenticated successfully and was issued an Ory Kratos Session Token.")
span.AddEvent(events.NewLoginSucceeded(ctx, &events.LoginSucceededOpts{
SessionID: s.ID,
IdentityID: i.ID,
FlowID: f.ID,
FlowType: string(f.Type),
RequestedAAL: string(f.RequestedAAL),
IsRefresh: f.Refresh,
Method: f.Active.String(),
SSOProvider: provider,
}))
if f.IDToken != "" {
// We don't want to redirect with the code, if the flow was submitted with an ID token.
// This is the case for Sign in with native Apple SDK or Google SDK.
} else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, f, s.ID, g); err != nil {
return errors.WithStack(err)
} else if handled {
return nil
}
response := &APIFlowResponse{
Session: s,
Token: s.Token,
ContinueWith: f.ContinueWith(),
}
if e.checkAAL(ctx, classified, f) != nil {
// If AAL is not satisfied, we omit the identity to preserve the user's privacy in case of a phishing attack.
response.Session.Identity = nil
}
e.d.Writer().Write(w, r, response)
return nil
}
if err := e.d.SessionManager().UpsertAndIssueCookie(ctx, w, r, s); err != nil {
return errors.WithStack(err)
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("session_id", s.ID).
Info("Identity authenticated successfully and was issued an Ory Kratos Session Cookie.")
span.AddEvent(events.NewLoginSucceeded(ctx, &events.LoginSucceededOpts{
SessionID: s.ID,
FlowID: f.ID,
IdentityID: i.ID, FlowType: string(f.Type), RequestedAAL: string(f.RequestedAAL), IsRefresh: f.Refresh, Method: f.Active.String(),
SSOProvider: provider,
}))
if x.IsJSONRequest(r) {
span.SetAttributes(attribute.String("flow_type", "spa"))
// Browser flows rely on cookies. Adding tokens in the mix will confuse consumers.
s.Token = ""
// If we detect that whoami would require a higher AAL, we redirect!
if err := e.checkAAL(ctx, classified, f); err != nil {
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
if data, _ := flow.DuplicateCredentials(f); data == nil {
span.SetAttributes(attribute.String("return_to", aalErr.RedirectTo), attribute.String("redirect_reason", "requires aal2"))
e.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(aalErr.RedirectTo))
return nil
}
// Special case: If we are in a flow that wants to link credentials, we create a
// new login flow here that asks for the require AAL, but also copies over the
// internal context and the organization ID.
r.URL, err = url.Parse(aalErr.RedirectTo)
if err != nil {
return errors.WithStack(err)
}
newFlow, _, err := e.d.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser,
WithInternalContext(f.InternalContext),
WithOrganizationID(f.OrganizationID),
)
if err != nil {
return errors.WithStack(err)
}
x.SendFlowCompletedAsRedirectOrJSON(w, r, e.d.Writer(), newFlow, newFlow.AppendTo(e.d.Config().SelfServiceFlowLoginUI(ctx)).String())
return nil
}
return err
}
// If Kratos is used as a Hydra login provider, we need to redirect back to Hydra by returning a 422 status
// with the post login challenge URL as the body.
if f.OAuth2LoginChallenge != "" {
postChallengeURL, err := e.d.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(f.OAuth2LoginChallenge),
IdentityID: i.ID.String(),
SessionID: s.ID.String(),
AuthenticationMethods: s.AMR,
})
if err != nil {
return err
}
span.SetAttributes(attribute.String("return_to", postChallengeURL), attribute.String("redirect_reason", "oauth2 login challenge"))
e.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(postChallengeURL))
return nil
}
response := &APIFlowResponse{
Session: s,
ContinueWith: f.ContinueWith(),
}
e.d.Writer().Write(w, r, response)
return nil
}
// If we detect that whoami would require a higher AAL, we redirect!
if err := e.checkAAL(ctx, classified, f); err != nil {
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
if data, _ := flow.DuplicateCredentials(f); data == nil {
http.Redirect(w, r, aalErr.RedirectTo, http.StatusSeeOther)
return nil
}
// Special case: If we are in a flow that wants to link credentials, we create a
// new login flow here that asks for the require AAL, but also copies over the
// internal context and the organization ID.
r.URL, err = url.Parse(aalErr.RedirectTo)
if err != nil {
return errors.WithStack(err)
}
newFlow, _, err := e.d.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser,
WithInternalContext(f.InternalContext),
WithOrganizationID(f.OrganizationID),
)
if err != nil {
return errors.WithStack(err)
}
x.SendFlowCompletedAsRedirectOrJSON(w, r, e.d.Writer(), newFlow, newFlow.AppendTo(e.d.Config().SelfServiceFlowLoginUI(ctx)).String())
return nil
}
return errors.WithStack(err)
}
finalReturnTo := returnTo.String()
if f.OAuth2LoginChallenge != "" {
rt, err := e.d.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(f.OAuth2LoginChallenge),
IdentityID: i.ID.String(),
SessionID: s.ID.String(),
AuthenticationMethods: s.AMR,
})
if err != nil {
return err
}
finalReturnTo = rt
span.SetAttributes(attribute.String("return_to", rt), attribute.String("redirect_reason", "oauth2 login challenge"))
} else if f.ReturnToVerification != "" {
finalReturnTo = f.ReturnToVerification
span.SetAttributes(attribute.String("redirect_reason", "verification requested"))
}
redir.ContentNegotiationRedirection(w, r, s, e.d.Writer(), finalReturnTo)
return nil
}
func (e *HookExecutor) PreLoginHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
hooks, err := e.d.PreLoginHooks(r.Context())
if err != nil {
return err
}
for _, h := range hooks {
if err := h.ExecuteLoginPreHook(w, r, a); err != nil {
return err
}
}
return nil
}
// maybeLinkCredentials links the identity with the credentials of the inner context of the login flow.
func (e *HookExecutor) maybeLinkCredentials(ctx context.Context, sess *session.Session, ident *identity.Identity, loginFlow *Flow) (err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostLoginHook.maybeLinkCredentials")
defer otelx.End(span, &err)
if e.checkAAL(ctx, sess, loginFlow) != nil {
// we don't yet want to link credentials because the required AAL is not satisfied
return nil
}
lc, err := flow.DuplicateCredentials(loginFlow)
if err != nil {
return err
} else if lc == nil {
return nil
}
if err = e.checkDuplicateCredentialsIdentifierMatch(ctx, ident, lc.DuplicateIdentifier); err != nil {
return err
}
strategy, err := e.d.AllLoginStrategies().Strategy(lc.CredentialsType)
if err != nil {
return err
}
linkableStrategy, ok := strategy.(LinkableStrategy)
if !ok {
// This should never happen because we check for this in the registration flow.
return errors.Errorf("strategy is not linkable: %T", linkableStrategy)
}
if err := linkableStrategy.Link(ctx, ident, lc.CredentialsConfig); err != nil {
return err
}
if err = linkableStrategy.CompletedLogin(sess, lc); err != nil {
return err
}
return nil
}
func (e *HookExecutor) checkDuplicateCredentialsIdentifierMatch(ctx context.Context, i *identity.Identity, match string) error {
if len(i.Credentials) == 0 {
if err := e.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil {
return err
}
}
for _, credentials := range i.Credentials {
for _, identifier := range credentials.Identifiers {
if identifier == match {
return nil
}
}
}
return schema.NewLinkedCredentialsDoNotMatch()
}

View File

@@ -0,0 +1,536 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ory/x/configx"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/login"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
)
func TestLoginExecutor(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, strategy := range identity.AllCredentialTypes {
t.Run("strategy="+strategy.String(), func(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t)
reg.SetHydra(hydra.NewFake())
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL)
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
newServer := func(t *testing.T, ft flow.Type, useIdentity *identity.Identity, flowCallback ...func(*login.Flow)) *httptest.Server {
router := http.NewServeMux()
router.HandleFunc("GET /login/pre", func(w http.ResponseWriter, r *http.Request) {
loginFlow, err := login.NewFlow(reg, r, ft)
require.NoError(t, err)
if testhelpers.SelfServiceHookLoginErrorHandler(t, w, r, reg.LoginHookExecutor().PreLoginHook(w, r, loginFlow)) {
_, _ = w.Write([]byte("ok"))
}
})
router.HandleFunc("GET /login/post", func(w http.ResponseWriter, r *http.Request) {
loginFlow, err := login.NewFlow(reg, r, ft)
require.NoError(t, err)
loginFlow.Active = strategy
loginFlow.RequestURL = x.RequestURL(r).String()
for _, cb := range flowCallback {
cb(loginFlow)
}
sess := session.NewInactiveSession()
sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
if useIdentity == nil {
useIdentity = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
testhelpers.SelfServiceHookLoginErrorHandler(t, w, r,
reg.LoginHookExecutor().PostLoginHook(w, r, strategy.ToUiNodeGroup(), loginFlow, useIdentity, sess, ""))
})
router.HandleFunc("GET /login/post2fa", func(w http.ResponseWriter, r *http.Request) {
loginFlow, err := login.NewFlow(reg, r, ft)
require.NoError(t, err)
loginFlow.Active = strategy
loginFlow.RequestURL = x.RequestURL(r).String()
for _, cb := range flowCallback {
cb(loginFlow)
}
sess := session.NewInactiveSession()
sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
sess.CompletedLoginFor(identity.CredentialsTypeTOTP, identity.AuthenticatorAssuranceLevel2)
if useIdentity == nil {
useIdentity = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
}
testhelpers.SelfServiceHookLoginErrorHandler(t, w, r,
reg.LoginHookExecutor().PostLoginHook(w, r, strategy.ToUiNodeGroup(), loginFlow, useIdentity, sess, ""))
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
return ts
}
makeRequestPost := testhelpers.SelfServiceMakeLoginPostHookRequest
viperSetPost := testhelpers.SelfServiceHookLoginViperSetPost
t.Run("method=PostLoginHook", 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, flow.TypeBrowser, nil), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("case=pass without hooks if client is ajax", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
ts := newServer(t, flow.TypeBrowser, nil)
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.JSONEq(t, fmt.Sprintf(`[{"action":"redirect_browser_to","redirect_browser_to":"%s"}]`, returnToServer.URL), gjson.Get(body, "continue_with").Raw)
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("case=fail if hooks fail", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPostHook": "abort"}`)}})
ts := newServer(t, flow.TypeBrowser, nil)
res, body := makeRequestPost(t, ts, false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), ts.URL)
assert.Empty(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})
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{"return_to": {returnToServer.URL + "/kratos"}})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(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))
conf.MustSet(ctx, config.ViperKeySelfServiceLoginAfter+"."+config.DefaultBrowserReturnURL, returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
require.EqualValuesf(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.SelfServiceHookLoginSetDefaultRedirectTo(t, conf, returnToServer.URL+"/not-kratos")
testhelpers.SelfServiceHookLoginSetDefaultRedirectToStrategy(t, conf, strategy.String(), returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
require.EqualValuesf(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(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("case=send a json response for API clients", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, nil), true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.identity.id").String())
})
t.Run("suite=handle login challenge with browser and application/json", func(t *testing.T) {
t.Run("case=includes the return_to address for a valid challenge", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
withOAuthChallenge := func(f *login.Flow) {
f.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge
}
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil, withOAuthChallenge), true, url.Values{})
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
assert.Equal(t, hydra.FakePostLoginURL, gjson.Get(body, "redirect_browser_to").String(), "%s", body)
})
t.Run("case=returns an error for an invalid challenge", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
withOAuthChallenge := func(f *login.Flow) {
f.OAuth2LoginChallenge = hydra.FakeInvalidLoginChallenge
}
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil, withOAuthChallenge), true, url.Values{})
require.EqualValuesf(t, http.StatusInternalServerError, res.StatusCode, "%s", body)
assert.Equal(t, hydra.ErrFakeAcceptLoginRequestFailed.Error(), body, "%s", body)
})
})
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, flow.TypeBrowser, nil), true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.identity.id").String())
assert.Empty(t, gjson.Get(body, "session.token").String())
assert.Empty(t, gjson.Get(body, "session_token").String())
})
t.Run("case=work normally if AAL is satisfied", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
useIdentity := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword},
identity.CredentialsTypeTOTP: {Type: identity.CredentialsTypeTOTP},
}}
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), useIdentity))
t.Run("browser client", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("api client returns the session with identity and the token", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, useIdentity), true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.identity").String())
assert.NotEmpty(t, gjson.Get(body, "session_token").String())
})
t.Run("browser JSON client returns the session with identity but not the token", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "session.id").String())
assert.NotEmpty(t, gjson.Get(body, "session.identity").String())
assert.Empty(t, gjson.Get(body, "session_token").String())
})
})
t.Run("case=redirect to login if AAL is too low", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "highest_available")
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
})
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra")
useIdentity := &identity.Identity{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{testhelpers.RandomEmail()}},
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), Identifiers: []string{testhelpers.RandomEmail()}},
}}
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), useIdentity))
t.Run("browser client", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{})
require.EqualValuesf(t, http.StatusNotFound, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), "/self-service/login/browser?aal=aal2")
})
t.Run("browser client with login challenge", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{
"login_challenge": []string{hydra.FakeValidLoginChallenge},
})
require.EqualValuesf(t, http.StatusNotFound, res.StatusCode, "%s", body)
assert.Equal(t, res.Request.URL.Path, "/self-service/login/browser")
assert.Equal(t, res.Request.URL.Query().Get("aal"), "aal2")
assert.Equal(t, res.Request.URL.Query().Get("login_challenge"), hydra.FakeValidLoginChallenge)
})
t.Run("api client returns the token and the session without the identity", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, useIdentity), true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Empty(t, gjson.Get(body, "session.identity").String())
assert.NotEmpty(t, gjson.Get(body, "session_token").String())
})
t.Run("browser JSON client", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{})
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String())
assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "/self-service/login/browser?aal=aal2", "%s", body)
})
t.Run("browser JSON client with login challenge", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{
"login_challenge": []string{hydra.FakeValidLoginChallenge},
})
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String())
redirectBrowserTo, err := url.Parse(gjson.Get(body, "redirect_browser_to").String())
require.NoError(t, err)
assert.Equal(t, redirectBrowserTo.Path, "/self-service/login/browser")
assert.Equal(t, redirectBrowserTo.Query().Get("aal"), "aal2")
assert.Equal(t, redirectBrowserTo.Query().Get("login_challenge"), hydra.FakeValidLoginChallenge)
})
})
})
t.Run("case=maybe links credential", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
conf.MustSet(ctx, "selfservice.methods.totp.enabled", true)
email1, email2 := testhelpers.RandomEmail(), testhelpers.RandomEmail()
passwordOnlyIdentity := &identity.Identity{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{email1},
},
}}
twoFAIdentitiy := &identity.Identity{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{email2},
},
identity.CredentialsTypeTOTP: {
Type: identity.CredentialsTypeTOTP,
Config: []byte(`{"totp_url":"otpauth://totp/test"}`),
Identifiers: []string{email2},
},
}}
require.NoError(t, reg.Persister().CreateIdentity(ctx, passwordOnlyIdentity))
require.NoError(t, reg.Persister().CreateIdentity(ctx, twoFAIdentitiy))
credsOIDCPWOnly, err := identity.NewCredentialsOIDC(
&identity.CredentialsOIDCEncryptedTokens{IDToken: "id-token", AccessToken: "access-token", RefreshToken: "refresh-token"},
"my-provider",
email1,
"",
)
require.NoError(t, err)
credsOIDC2FA, err := identity.NewCredentialsOIDC(
&identity.CredentialsOIDCEncryptedTokens{IDToken: "id-token", AccessToken: "access-token", RefreshToken: "refresh-token"},
"my-provider",
email2,
"",
)
require.NoError(t, err)
t.Run("sub-case=does not link after first factor when second factor is available", func(t *testing.T) {
duplicateCredentialsData := flow.DuplicateCredentialsData{
CredentialsType: identity.CredentialsTypeOIDC,
CredentialsConfig: credsOIDC2FA.Config,
DuplicateIdentifier: email2,
}
ts := newServer(t, flow.TypeBrowser, twoFAIdentitiy, func(l *login.Flow) {
require.NoError(t, flow.SetDuplicateCredentials(l, duplicateCredentialsData))
})
res, _ := makeRequestPost(t, ts, false, url.Values{})
assert.Equal(t, reg.Config().SelfServiceFlowLoginUI(ctx).Host, res.Request.URL.Host)
assert.Equal(t, reg.Config().SelfServiceFlowLoginUI(ctx).Path, res.Request.URL.Path)
newFlowID := res.Request.URL.Query().Get("flow")
assert.NotEmpty(t, newFlowID)
newFlow, err := reg.LoginFlowPersister().GetLoginFlow(ctx, uuid.Must(uuid.FromString(newFlowID)))
require.NoError(t, err)
newFlowDuplicateCredentialsData, err := flow.DuplicateCredentials(newFlow)
require.NoError(t, err)
// Duplicate credentials data should have been copied over
assert.Equal(t, duplicateCredentialsData.CredentialsType, newFlowDuplicateCredentialsData.CredentialsType)
assert.Equal(t, duplicateCredentialsData.DuplicateIdentifier, newFlowDuplicateCredentialsData.DuplicateIdentifier)
assert.JSONEq(t, string(duplicateCredentialsData.CredentialsConfig), string(newFlowDuplicateCredentialsData.CredentialsConfig))
// AAL should be AAL2
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, newFlow.RequestedAAL)
// TOTP nodes should be present
found := false
for _, n := range newFlow.UI.Nodes {
if n.Group == node.TOTPGroup {
found = true
break
}
}
assert.True(t, found, "could not find TOTP nodes in %+v", newFlow.UI.Nodes)
ident, err := reg.Persister().GetIdentity(ctx, twoFAIdentitiy.ID, identity.ExpandCredentials)
require.NoError(t, err)
assert.Len(t, ident.Credentials, 2)
})
t.Run("sub-case=links after second factor when second factor is available", func(t *testing.T) {
ts := newServer(t, flow.TypeBrowser, twoFAIdentitiy, func(l *login.Flow) {
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
CredentialsType: identity.CredentialsTypeOIDC,
CredentialsConfig: credsOIDC2FA.Config,
DuplicateIdentifier: email2,
}))
})
res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/login/post2fa", false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equalf(t, returnToServer.URL, res.Request.URL.String(), "%s", body)
ident, err := reg.Persister().GetIdentity(ctx, twoFAIdentitiy.ID, identity.ExpandCredentials)
require.NoError(t, err)
assert.Len(t, ident.Credentials, 3)
})
t.Run("sub-case=links matching identity", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, passwordOnlyIdentity, func(l *login.Flow) {
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
CredentialsType: identity.CredentialsTypeOIDC,
CredentialsConfig: credsOIDCPWOnly.Config,
DuplicateIdentifier: email1,
}))
}), false, url.Values{})
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equalf(t, returnToServer.URL, res.Request.URL.String(), "%s", body)
ident, err := reg.Persister().GetIdentity(ctx, passwordOnlyIdentity.ID, identity.ExpandCredentials)
require.NoError(t, err)
assert.Len(t, ident.Credentials, 2)
})
t.Run("sub-case=errors on non-matching identity", func(t *testing.T) {
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, passwordOnlyIdentity, func(l *login.Flow) {
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
CredentialsType: identity.CredentialsTypeOIDC,
CredentialsConfig: credsOIDCPWOnly.Config,
DuplicateIdentifier: "wrong@example.com",
}))
}), false, url.Values{})
require.EqualValues(t, http.StatusInternalServerError, res.StatusCode)
assert.Equal(t, schema.NewLinkedCredentialsDoNotMatch().Error(), body, "%s", body)
})
})
t.Run("type=api", func(t *testing.T) {
t.Run("method=PreLoginHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceLoginBeforeHooks,
testhelpers.SelfServiceMakeLoginPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, flow.TypeAPI, nil)
},
conf,
))
})
t.Run("type=browser", func(t *testing.T) {
t.Run("method=PreLoginHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceLoginBeforeHooks,
testhelpers.SelfServiceMakeLoginPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, flow.TypeBrowser, nil)
},
conf,
))
})
})
}
t.Run("method=checkAAL", func(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{
config.ViperKeyPublicBaseURL: returnToServer.URL,
config.ViperKeySelfServiceBrowserDefaultReturnTo: returnToServer.URL,
}), configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")))
t.Run("returns no error when sufficient", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, identity.AuthenticatorAssuranceLevel1)
assert.NoError(t,
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
AMR: session.AuthenticationMethods{{
Method: identity.CredentialsTypePassword,
AAL: identity.AuthenticatorAssuranceLevel1,
}},
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel1,
}, nil),
)
ctx = contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
assert.NoError(t,
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
AMR: session.AuthenticationMethods{{
Method: identity.CredentialsTypePassword,
AAL: identity.AuthenticatorAssuranceLevel1,
}, {
Method: identity.CredentialsTypeLookup,
AAL: identity.AuthenticatorAssuranceLevel2,
}},
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel2,
}, nil),
)
})
t.Run("copies parameters to redirect URL when AAL is not sufficient", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
aalErr := new(session.ErrAALNotSatisfied)
require.ErrorAs(t,
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
AMR: session.AuthenticationMethods{{
Method: identity.CredentialsTypePassword,
AAL: identity.AuthenticatorAssuranceLevel1,
}},
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel1,
Identity: &identity.Identity{
InternalAvailableAAL: identity.NullableAuthenticatorAssuranceLevel{
NullString: sql.NullString{String: string(identity.AuthenticatorAssuranceLevel2), Valid: true},
},
},
}, &login.Flow{
RequestURL: "https://www.ory.com/?return_to=https://www.ory.com/kratos&login_challenge=challenge",
}),
&aalErr,
)
assert.Equal(t, returnToServer.URL+"/self-service/login/browser?aal=aal2&login_challenge=challenge&return_to=https%3A%2F%2Fwww.ory.com%2Fkratos", aalErr.RedirectTo)
})
})
}

View File

@@ -0,0 +1,42 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"net/http"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
)
var organizationFilter = []StrategyFilter{func(s Strategy) bool {
a, b := s.(flow.OrganizationImplementor)
return b && a.SupportsOrganizations()
}}
func PrepareOrganizations(r *http.Request, f *Flow, sess *session.Session) []StrategyFilter {
if f.RequestedAAL != identity.AuthenticatorAssuranceLevel1 {
return []StrategyFilter{}
}
if f.OrganizationID.Valid {
return organizationFilter
}
orgID := flow.ParseOrganizationFromURLQuery(r.Context(), r.URL.Query())
if sess != nil && sess.Identity != nil && sess.Identity.OrganizationID.Valid {
orgID = sess.Identity.OrganizationID
}
if !orgID.Valid {
return []StrategyFilter{}
}
f.OrganizationID = orgID
// We only apply the filter on AAL1, because the OIDC strategy can only satsify
// AAL1.
return organizationFilter
}

View File

@@ -0,0 +1,137 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"net/http"
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
)
func TestPrepareOrganizations(t *testing.T) {
t.Run("should return empty filter if AAL is not AAL1", func(t *testing.T) {
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel2}
r := &http.Request{URL: new(url.URL)}
sess := &session.Session{}
filters := PrepareOrganizations(r, f, sess)
assert.Empty(t, filters)
})
t.Run("should return empty filter if OrganizationID is not set", func(t *testing.T) {
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
r := &http.Request{URL: new(url.URL)}
sess := &session.Session{}
filters := PrepareOrganizations(r, f, sess)
assert.Empty(t, filters)
})
t.Run("should return organization filter if OrganizationID is valid", func(t *testing.T) {
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
r := &http.Request{URL: new(url.URL)}
sess := &session.Session{
Identity: &identity.Identity{OrganizationID: uuid.NullUUID{Valid: true}},
}
filters := PrepareOrganizations(r, f, sess)
require.NotEmpty(t, filters)
assert.Equal(t, organizationFilter, filters)
})
t.Run("should parse OrganizationID from URL query if not in session", func(t *testing.T) {
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
r := &http.Request{
URL: &url.URL{
RawQuery: "organization=123e4567-e89b-12d3-a456-426614174000",
},
}
sess := &session.Session{}
filters := PrepareOrganizations(r, f, sess)
require.NotEmpty(t, filters)
assert.Equal(t, organizationFilter, filters)
})
t.Run("should use organization ID already in flow", func(t *testing.T) {
orgID := uuid.NullUUID{UUID: uuid.Must(uuid.NewV4()), Valid: true}
f := &Flow{
RequestedAAL: identity.AuthenticatorAssuranceLevel1,
OrganizationID: orgID,
}
r := &http.Request{URL: new(url.URL)}
sess := &session.Session{}
filters := PrepareOrganizations(r, f, sess)
require.NotEmpty(t, filters)
assert.Equal(t, organizationFilter, filters)
assert.Equal(t, orgID, f.OrganizationID)
})
t.Run("should prioritize session org ID over query param when both present", func(t *testing.T) {
sessionOrgID := uuid.Must(uuid.NewV4())
queryOrgID := uuid.Must(uuid.NewV4())
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
r := &http.Request{
URL: &url.URL{
RawQuery: "organization=" + queryOrgID.String(),
},
}
sess := &session.Session{
Identity: &identity.Identity{
OrganizationID: uuid.NullUUID{UUID: sessionOrgID, Valid: true},
},
}
filters := PrepareOrganizations(r, f, sess)
require.NotEmpty(t, filters)
assert.Equal(t, organizationFilter, filters)
assert.Equal(t, sessionOrgID, f.OrganizationID.UUID)
})
t.Run("should not return filter when organization is set but AAL2 requested", func(t *testing.T) {
orgID := uuid.NullUUID{UUID: uuid.Must(uuid.NewV4()), Valid: true}
f := &Flow{
RequestedAAL: identity.AuthenticatorAssuranceLevel2,
OrganizationID: orgID,
}
r := &http.Request{URL: new(url.URL)}
sess := &session.Session{}
filters := PrepareOrganizations(r, f, sess)
assert.Empty(t, filters)
// Organization ID should remain set in the flow
assert.Equal(t, orgID, f.OrganizationID)
})
t.Run("should use organization ID from query when session has no org ID", func(t *testing.T) {
queryOrgID := uuid.Must(uuid.NewV4())
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
r := &http.Request{
URL: &url.URL{
RawQuery: "organization=" + queryOrgID.String(),
},
}
sess := &session.Session{
Identity: &identity.Identity{
// No organization ID set
OrganizationID: uuid.NullUUID{Valid: false},
},
}
filters := PrepareOrganizations(r, f, sess)
require.NotEmpty(t, filters)
assert.Equal(t, organizationFilter, filters)
assert.Equal(t, queryOrgID, f.OrganizationID.UUID)
})
}

View File

@@ -0,0 +1,25 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type (
FlowPersister interface {
UpdateLoginFlow(context.Context, *Flow) error
CreateLoginFlow(context.Context, *Flow) error
GetLoginFlow(context.Context, uuid.UUID) (*Flow, error)
ForceLoginFlow(ctx context.Context, id uuid.UUID) error
DeleteExpiredLoginFlows(context.Context, time.Time, int) error
DeleteTestLoginFlow(context.Context, uuid.UUID) error
}
FlowPersistenceProvider interface {
LoginFlowPersister() FlowPersister
}
)

View File

@@ -0,0 +1,40 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
)
// The Response for Login Flows via API
//
// swagger:model successfulNativeLogin
type APIFlowResponse struct {
// The Session Token
//
// A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization
// Header:
//
// Authorization: bearer ${session-token}
//
// The session token is only issued for API flows, not for Browser flows!
Token string `json:"session_token,omitempty"`
// The Session
//
// The session contains information about the user, the session device, and so on.
// This is only available for API flows, not for Browser flows!
//
// required: true
Session *session.Session `json:"session"`
// Contains a list of actions, that could follow this flow
//
// It can, for example, this will contain a reference to the verification flow, created as part of the user's
// registration or the token of the session.
//
// required: false
ContinueWith []flow.ContinueWith `json:"continue_with"`
}

View File

@@ -0,0 +1,30 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"github.com/ory/kratos/ui/node"
)
func sortNodes(ctx context.Context, n node.Nodes) error {
return n.SortBySchema(ctx,
node.SortByGroups([]node.UiNodeGroup{
node.OpenIDConnectGroup,
node.DefaultGroup,
node.WebAuthnGroup,
node.PasskeyGroup,
node.CodeGroup,
node.PasswordGroup,
node.TOTPGroup,
node.LookupGroup,
}),
node.SortUseOrder([]string{
"csrf_token",
"identifier",
"password",
}),
)
}

View File

@@ -0,0 +1,17 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import "github.com/ory/kratos/selfservice/flow"
// Login Flow State
//
// The state represents the state of the login flow.
//
// - choose_method: ask the user to choose a method (e.g. login account via email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the login challenge was passed.
//
// swagger:model loginFlowState
type State = flow.State

View File

@@ -0,0 +1,64 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/sqlxx"
)
type Strategy interface {
ID() identity.CredentialsType
NodeGroup() node.UiNodeGroup
Login(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (i *identity.Identity, err error)
CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod
}
type Strategies []Strategy
type LinkableStrategy interface {
Link(ctx context.Context, i *identity.Identity, credentials sqlxx.JSONRawMessage) error
CompletedLogin(sess *session.Session, data *flow.DuplicateCredentialsData) error
SetDuplicateCredentials(f flow.InternalContexter, duplicateIdentifier string, credentials identity.Credentials, provider string) error
}
type FastLoginStrategy interface {
FastLogin1FA(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (err error)
FastLogin2FA(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (err error)
}
func (s Strategies) Strategy(id identity.CredentialsType) (Strategy, error) {
ids := make([]identity.CredentialsType, len(s))
for k, ss := range s {
ids[k] = ss.ID()
if ss.ID() == id {
return ss, nil
}
}
return nil, errors.Errorf(`unable to find strategy for %s have %v`, id, ids)
}
func (s Strategies) MustStrategy(id identity.CredentialsType) Strategy {
strategy, err := s.Strategy(id)
if err != nil {
panic(err)
}
return strategy
}
type StrategyFilter func(strategy Strategy) bool
type StrategyProvider interface {
AllLoginStrategies() Strategies
LoginStrategies(ctx context.Context, filters ...StrategyFilter) Strategies
}

View File

@@ -0,0 +1,73 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
stderr "errors"
"net/http"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
)
type UnifiedFormHydrator interface {
ID() identity.CredentialsType
PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *Flow) error
}
type AAL1FormHydrator interface {
ID() identity.CredentialsType
PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *Flow, sess *session.Session) error
PopulateLoginMethodFirstFactor(r *http.Request, sr *Flow) error
// PopulateLoginMethodIdentifierFirstCredentials populates the login form with the first factor credentials.
// This method is called when the login flow is set to identifier first. The method will receive information
// about the identity that is being used to log in and the identifier that was used to find the identity.
//
// The method should populate the login form with the credentials of the identity.
//
// If the method can not find any credentials (because the identity does not exist) idfirst.ErrNoCredentialsFound
// must be returned. When returning idfirst.ErrNoCredentialsFound the strategy will appropriately deal with
// account enumeration mitigation.
//
// This method does however need to take appropriate steps to show/hide certain fields depending on the account
// enumeration configuration.
PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *Flow, options ...FormHydratorModifier) error
PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *Flow) error
}
type AAL2FormHydrator interface {
ID() identity.CredentialsType
PopulateLoginMethodSecondFactor(r *http.Request, sr *Flow) error
PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *Flow) error
}
var ErrBreakLoginPopulate = stderr.New("skip rest of login form population")
type FormHydratorOptions struct {
IdentityHint *identity.Identity
Identifier string
}
type FormHydratorModifier func(o *FormHydratorOptions)
func WithIdentityHint(i *identity.Identity) FormHydratorModifier {
return func(o *FormHydratorOptions) {
o.IdentityHint = i
}
}
func WithIdentifier(i string) FormHydratorModifier {
return func(o *FormHydratorOptions) {
o.Identifier = i
}
}
func NewFormHydratorOptions(modifiers []FormHydratorModifier) *FormHydratorOptions {
o := new(FormHydratorOptions)
for _, m := range modifiers {
m(o)
}
return o
}

View File

@@ -0,0 +1,24 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/identity"
)
func TestWithIdentityHint(t *testing.T) {
expected := new(identity.Identity)
opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentityHint(expected)})
assert.Equal(t, expected, opts.IdentityHint)
}
func TestWithIdentifier(t *testing.T) {
expected := "identifier"
opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentifier(expected)})
assert.Equal(t, expected, opts.Identifier)
}

View File

@@ -0,0 +1,24 @@
{
"$id": "https://example.com/email.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"title": "E-Mail Address",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"$id": "https://example.com/phone.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"title": "Phone Number",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,272 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"fmt"
"testing"
"time"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/persistence"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/sqlcon"
)
func TestFlowPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
var clearids = func(r *login.Flow) {
r.ID = uuid.UUID{}
}
return func(t *testing.T) {
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=should error when the login flow does not exist", func(t *testing.T) {
_, err := p.GetLoginFlow(ctx, x.NewUUID())
require.Error(t, err)
})
var newFlow = func(t *testing.T) *login.Flow {
var r login.Flow
require.NoError(t, faker.FakeData(&r))
clearids(&r)
return &r
}
t.Run("case=should create with set ids", func(t *testing.T) {
var r login.Flow
require.NoError(t, faker.FakeData(&r))
require.NoError(t, p.CreateLoginFlow(ctx, &r))
})
t.Run("case=should create a new login flow and properly set IDs", func(t *testing.T) {
r := newFlow(t)
err := p.CreateLoginFlow(ctx, r)
require.NoError(t, err, "%#v", err)
assert.NotEqual(t, uuid.Nil, r.ID)
})
t.Run("case=should create and fetch a login flow", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateLoginFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetLoginFlow(ctx, expected.ID)
require.NoError(t, err)
assert.EqualValues(t, expected.ID, actual.ID)
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.Active, actual.Active)
assertx.EqualAsJSON(t, expected.UI, actual.UI, "expected:\t%s\nactual:\t%s", expected.UI, actual.UI)
})
t.Run("case=should properly set the flow type", func(t *testing.T) {
expected := newFlow(t)
expected.Refresh = true
expected.Type = flow.TypeAPI
expected.UI = container.New("ory-sh")
err := p.CreateLoginFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetLoginFlow(ctx, expected.ID)
require.NoError(t, err)
assert.Equal(t, flow.TypeAPI, actual.Type)
actual.UI = container.New("not-ory-sh")
actual.Type = flow.TypeBrowser
actual.Refresh = true
require.NoError(t, p.UpdateLoginFlow(ctx, actual))
actual, err = p.GetLoginFlow(ctx, actual.ID)
require.NoError(t, err)
assert.Equal(t, flow.TypeBrowser, actual.Type)
assert.True(t, actual.Refresh)
assert.Equal(t, "not-ory-sh", actual.UI.Action)
})
t.Run("case=should not cause data loss when updating a request without changes", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateLoginFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetLoginFlow(ctx, expected.ID)
require.NoError(t, err)
require.NoError(t, p.UpdateLoginFlow(ctx, actual))
actual, err = p.GetLoginFlow(ctx, expected.ID)
require.NoError(t, err)
assertx.EqualAsJSON(t, expected.UI, actual.UI)
})
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.CreateLoginFlow(ctx, r))
assert.Equal(t, tc.expect, string(r.InternalContext))
r.InternalContext = tc.in
require.NoError(t, p.UpdateLoginFlow(ctx, r))
assert.Equal(t, tc.expect, string(r.InternalContext))
})
}
})
t.Run("case=network", func(t *testing.T) {
id := x.NewUUID()
nid, p := testhelpers.NewNetwork(t, ctx, p)
t.Run("sets id on creation", func(t *testing.T) {
expected := &login.Flow{ID: id, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}
require.NoError(t, p.CreateLoginFlow(ctx, expected))
assert.EqualValues(t, id, expected.ID)
assert.EqualValues(t, nid, expected.NID)
actual, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
assert.EqualValues(t, id, actual.ID)
assert.EqualValues(t, nid, actual.NID)
})
t.Run("can not update id", func(t *testing.T) {
expected, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
require.NoError(t, p.UpdateLoginFlow(ctx, expected))
actual, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
assert.EqualValues(t, id, actual.ID)
assert.EqualValues(t, nid, actual.NID)
})
t.Run("can not force on another network", func(t *testing.T) {
expected, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
_, other := testhelpers.NewNetwork(t, ctx, p)
expected.RequestURL = "updated"
require.Error(t, other.ForceLoginFlow(ctx, expected.ID))
_, err = other.GetLoginFlow(ctx, id)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
actual, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
require.False(t, actual.Refresh)
})
t.Run("can not update on another network", func(t *testing.T) {
expected, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
_, other := testhelpers.NewNetwork(t, ctx, p)
expected.RequestURL = "updated"
require.Error(t, other.UpdateLoginFlow(ctx, expected))
_, err = other.GetLoginFlow(ctx, id)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
actual, err := p.GetLoginFlow(ctx, id)
require.NoError(t, err)
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.GetLoginFlow(ctx, id)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=test-flow persister helpers", func(t *testing.T) {
// seedFlow creates a login flow under the given persister. When
// isTest is true the flow is marked as a test flow.
seedFlow := func(t *testing.T, p persistence.Persister, isTest bool, expiresAt time.Time) *login.Flow {
t.Helper()
f := &login.Flow{
ID: x.NewUUID(),
Type: flow.TypeBrowser,
State: flow.StateChooseMethod,
RequestURL: "https://example.com/self-service/login/browser",
IssuedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
CSRFToken: "csrf",
UI: container.New(""),
InternalContext: []byte(`{}`),
}
if isTest {
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
}
require.NoError(t, p.CreateLoginFlow(ctx, f))
return f
}
t.Run("DeleteTestLoginFlow rejects non-test-flow rows", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
f := seedFlow(t, p, false, time.Now().UTC().Add(time.Hour))
err := p.DeleteTestLoginFlow(ctx, f.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
// Row must still be there.
_, err = p.GetLoginFlow(ctx, f.ID)
require.NoError(t, err)
})
t.Run("DeleteTestLoginFlow rejects wrong NID", func(t *testing.T) {
_, pA := testhelpers.NewNetwork(t, ctx, p)
_, pB := testhelpers.NewNetwork(t, ctx, p)
fA := seedFlow(t, pA, true, time.Now().UTC().Add(time.Hour))
err := pB.DeleteTestLoginFlow(ctx, fA.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
// Row survives from the owning NID's perspective.
_, err = pA.GetLoginFlow(ctx, fA.ID)
require.NoError(t, err)
})
t.Run("DeleteTestLoginFlow removes only the targeted row", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
a := seedFlow(t, p, true, time.Now().UTC().Add(time.Hour))
b := seedFlow(t, p, true, time.Now().UTC().Add(time.Hour))
require.NoError(t, p.DeleteTestLoginFlow(ctx, a.ID))
_, err := p.GetLoginFlow(ctx, a.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
_, err = p.GetLoginFlow(ctx, b.ID)
require.NoError(t, err)
})
})
}
}

View File

@@ -0,0 +1,155 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"database/sql"
"encoding/json"
"github.com/pkg/errors"
"github.com/tidwall/sjson"
)
// InternalContextTestKey is the JSON path inside a login flow's
// internal_context where test-mode data (provider id, debug payload) is
// stored. Using a dedicated sub-namespace keeps test fields out of the
// flow's first-class columns while still letting a single row carry them.
const InternalContextTestKey = "test"
// Admin-test extension of a login flow. Populated only for flows created by
// the admin test endpoint; included in the flow's API response so the admin
// UI can render the pre-scoped provider and (once captured) the debug
// round-trip result.
//
// swagger:model loginFlowTestContext
type TestContext struct {
// The ID of the OIDC provider this test flow targets.
//
// required: true
ProviderID string `json:"provider_id"`
// Structured debug data captured during the provider round-trip.
// Nil while the flow is still awaiting the callback.
DebugPayload *DebugPayload `json:"debug_payload,omitempty"`
}
// Diagnostic payload captured during a test-mode OIDC round-trip.
// Contains the parsed claims, the Jsonnet mapper input and output, and any
// schema validation errors. Bearer tokens (id_token, access_token,
// refresh_token) are intentionally excluded to limit the blast radius of
// the debug payload leaking through audit logs or admin browsers.
//
// swagger:model loginFlowTestDebugPayload
type DebugPayload struct {
// Claims extracted from the ID token.
IDTokenClaims map[string]any `json:"id_token_claims,omitempty"`
// Claims returned from the provider's userinfo endpoint, if any.
Userinfo map[string]any `json:"userinfo,omitempty"`
// URL of the Jsonnet mapper that was executed on the claims.
JsonnetMapperURL string `json:"jsonnet_mapper_url,omitempty"`
// Input JSON that was fed into the Jsonnet mapper.
JsonnetInput map[string]any `json:"jsonnet_input,omitempty"`
// Output JSON returned by the Jsonnet mapper.
JsonnetOutput map[string]any `json:"jsonnet_output,omitempty"`
// Anything the Jsonnet mapper wrote to standard error.
JsonnetStderr string `json:"jsonnet_stderr,omitempty"`
// Identity-schema validation errors produced from the mapped traits.
SchemaValidationErrors []SchemaValidationError `json:"schema_validation_errors,omitempty"`
// A classified error if any step of the round-trip failed.
Error *StepError `json:"error,omitempty"`
}
// Classified failure of a step in an OIDC test round-trip.
// Populated when any step (token exchange, claims decode, Jsonnet
// evaluation, schema validation) cannot complete.
//
// swagger:model loginFlowTestStepError
type StepError struct {
// Machine-readable identifier of the failed step (for example,
// "token_exchange" or "schema_validate").
Step string `json:"step"`
// Short classification of the failure cause (for example,
// "access_denied" or "traits_invalid").
Reason string `json:"reason"`
// Human-readable message describing the failure.
Message string `json:"message"`
}
// One identity-schema validation failure recorded while evaluating the
// traits produced by the Jsonnet mapper.
//
// swagger:model loginFlowTestSchemaValidationError
type SchemaValidationError struct {
// JSON pointer to the field that failed validation.
Path string `json:"path"`
// Human-readable description of the validation failure.
Message string `json:"message"`
}
// IsTest reports whether this login flow is an admin-created test flow. The
// dedicated column lets persistence filter without parsing internal_context.
// A NULL or false test_flow column both read as false.
func (f *Flow) IsTest() bool { return f.TestFlow.Valid && f.TestFlow.Bool }
// LoadTestContext parses internal_context into the derived TestContext field
// so the flow's API response exposes it. A non-test flow or a flow with no
// test payload results in TestContext being nil.
func (f *Flow) LoadTestContext() error {
if !f.IsTest() || len(f.InternalContext) == 0 {
f.TestContext = nil
return nil
}
var shell struct {
Test *TestContext `json:"test"`
}
if err := json.Unmarshal(f.InternalContext, &shell); err != nil {
return errors.WithStack(err)
}
f.TestContext = shell.Test
return nil
}
// SetTestContext writes the derived TestContext back into internal_context
// and flips the TestFlow column. Call this after mutating f.TestContext (for
// example, when capturing a debug payload on the callback).
func (f *Flow) SetTestContext(tc *TestContext) error {
f.TestContext = tc
if tc == nil {
f.TestFlow = sql.NullBool{Valid: true, Bool: false}
if len(f.InternalContext) == 0 {
return nil
}
updated, err := sjson.DeleteBytes(f.InternalContext, InternalContextTestKey)
if err != nil {
return errors.WithStack(err)
}
f.InternalContext = updated
return nil
}
f.TestFlow = sql.NullBool{Valid: true, Bool: true}
if len(f.InternalContext) == 0 {
f.InternalContext = []byte("{}")
}
encoded, err := json.Marshal(tc)
if err != nil {
return errors.WithStack(err)
}
updated, err := sjson.SetRawBytes(f.InternalContext, InternalContextTestKey, encoded)
if err != nil {
return errors.WithStack(err)
}
f.InternalContext = updated
return nil
}

View File

@@ -0,0 +1,164 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"database/sql"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/sqlxx"
)
func TestFlow_IsTest(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tf sql.NullBool
want bool
}{
{name: "null", tf: sql.NullBool{Valid: false}, want: false},
{name: "false", tf: sql.NullBool{Valid: true, Bool: false}, want: false},
{name: "true", tf: sql.NullBool{Valid: true, Bool: true}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &login.Flow{TestFlow: tc.tf}
assert.Equal(t, tc.want, f.IsTest())
})
}
}
func TestFlow_LoadTestContext_NonTestFlow(t *testing.T) {
t.Parallel()
f := &login.Flow{
TestFlow: sql.NullBool{Valid: false},
InternalContext: sqlxx.JSONRawMessage(`{"test":{"provider_id":"google"},"foo":"bar"}`),
}
require.NoError(t, f.LoadTestContext())
assert.Nil(t, f.TestContext)
}
func TestFlow_LoadTestContext_EmptyInternalContext(t *testing.T) {
t.Parallel()
f := &login.Flow{
TestFlow: sql.NullBool{Valid: true, Bool: true},
InternalContext: nil,
}
require.NoError(t, f.LoadTestContext())
assert.Nil(t, f.TestContext)
}
func TestFlow_LoadTestContext_RoundTrip(t *testing.T) {
t.Parallel()
tc := &login.TestContext{
ProviderID: "google",
DebugPayload: &login.DebugPayload{
IDTokenClaims: map[string]any{"sub": "x"},
},
}
f := &login.Flow{InternalContext: sqlxx.JSONRawMessage(`{}`)}
require.NoError(t, f.SetTestContext(tc))
assert.True(t, f.IsTest())
// Simulate a round-trip through persistence by clearing the derived
// field and reloading from InternalContext.
f.TestContext = nil
require.NoError(t, f.LoadTestContext())
require.NotNil(t, f.TestContext)
assert.Equal(t, "google", f.TestContext.ProviderID)
require.NotNil(t, f.TestContext.DebugPayload)
assert.Equal(t, "x", f.TestContext.DebugPayload.IDTokenClaims["sub"])
}
func TestFlow_SetTestContext_Nil_RemovesKey(t *testing.T) {
t.Parallel()
f := &login.Flow{
InternalContext: sqlxx.JSONRawMessage(`{"test":{"provider_id":"google"},"foo":"bar"}`),
TestFlow: sql.NullBool{Valid: true, Bool: true},
}
require.NoError(t, f.SetTestContext(nil))
assert.False(t, f.IsTest())
require.NoError(t, f.LoadTestContext())
assert.Nil(t, f.TestContext)
assert.Equal(t, "bar", gjson.GetBytes(f.InternalContext, "foo").String())
assert.False(t, gjson.GetBytes(f.InternalContext, "test").Exists())
}
func TestFlow_SetTestContext_Nil_EmptyInternalContext(t *testing.T) {
t.Parallel()
f := &login.Flow{InternalContext: nil}
require.NoError(t, f.SetTestContext(nil))
assert.Equal(t, sql.NullBool{Valid: true, Bool: false}, f.TestFlow)
assert.True(t, len(f.InternalContext) == 0 || string(f.InternalContext) == "{}")
}
func TestFlow_SetTestContext_PreservesSiblingKeys(t *testing.T) {
t.Parallel()
f := &login.Flow{
InternalContext: sqlxx.JSONRawMessage(`{"foo":"bar","baz":1}`),
}
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "x"}))
assert.Equal(t, "bar", gjson.GetBytes(f.InternalContext, "foo").String())
assert.Equal(t, int64(1), gjson.GetBytes(f.InternalContext, "baz").Int())
assert.Equal(t, "x", gjson.GetBytes(f.InternalContext, "test.provider_id").String())
}
// newFlowForMarshal returns a minimal well-formed login flow suitable for
// MarshalJSON testing.
func newFlowForMarshal(t *testing.T) *login.Flow {
t.Helper()
return &login.Flow{
ID: x.NewUUID(),
UI: container.New(""),
InternalContext: sqlxx.JSONRawMessage(`{}`),
}
}
func TestFlow_MarshalJSON_TestFlowIncludesContext(t *testing.T) {
t.Parallel()
f := newFlowForMarshal(t)
require.NoError(t, f.SetTestContext(&login.TestContext{
ProviderID: "google",
DebugPayload: &login.DebugPayload{
IDTokenClaims: map[string]any{"sub": "alice"},
},
}))
b, err := json.Marshal(*f)
require.NoError(t, err)
assert.Equal(t, "google", gjson.GetBytes(b, "test_context.provider_id").String())
assert.Equal(t, "alice", gjson.GetBytes(b, "test_context.debug_payload.id_token_claims.sub").String())
}
func TestFlow_MarshalJSON_NonTestFlowOmitsContext(t *testing.T) {
t.Parallel()
f := newFlowForMarshal(t)
b, err := json.Marshal(*f)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(b, "test_context").Exists())
}
func TestFlow_MarshalJSON_UncapturedTestFlow(t *testing.T) {
t.Parallel()
f := newFlowForMarshal(t)
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
b, err := json.Marshal(*f)
require.NoError(t, err)
assert.Equal(t, "google", gjson.GetBytes(b, "test_context.provider_id").String())
assert.False(t, gjson.GetBytes(b, "test_context.debug_payload").Exists())
}

View File

@@ -0,0 +1,233 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login
import (
"context"
"encoding/json"
"net/http"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/x"
"github.com/ory/nosurf"
)
const (
// RouteAdminCreateTestFlow is the admin route for creating a dry-run OIDC
// test login flow. The admin router prepends /admin automatically, so the
// full path is POST /admin/test-login-flows.
RouteAdminCreateTestFlow = "/test-login-flows"
// RouteDeleteTestFlow is the public route for deleting a test login flow.
// It is authorized by the flow's CSRF bearer token, not by a session.
RouteDeleteTestFlow = "/self-service/login/test"
// maxProviderIDLength bounds the provider_id payload field. Provider IDs
// are short identifiers; anything longer indicates user error or abuse.
maxProviderIDLength = 255
)
// TestStrategy populates a login flow's UI with a single submit button
// for the given OIDC provider. The OIDC strategy implements this interface.
// Defined in the login package to avoid an import cycle.
type TestStrategy interface {
PopulateTestLoginFlow(r *http.Request, f *Flow, providerID string) error
}
// TestStrategyProvider exposes the enabled TestStrategy for the
// current request context.
type TestStrategyProvider interface {
TestStrategy(ctx context.Context) TestStrategy
}
// Parameters for creating a test OIDC login flow.
//
// swagger:parameters createTestLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createTestLoginFlow struct {
// in: body
// required: true
Body createTestLoginFlowBody
}
// Request body for creating a test OIDC login flow.
//
// swagger:model createTestLoginFlowBody
type createTestLoginFlowBody struct {
// ID of the OIDC provider to test. Must match a provider configured on
// the project that serves this request.
//
// required: true
// maxLength: 255
ProviderID string `json:"provider_id"`
}
// swagger:route POST /admin/test-login-flows identity createTestLoginFlow
//
// # Create a test OIDC login flow
//
// Creates a dry-run OIDC test login flow pre-scoped to one provider. The
// returned flow carries a single-submit UI and a CSRF bearer token. No
// identity is persisted and no session is issued when the flow completes;
// the captured debug data is returned in the flow's test_context.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 201: loginFlow
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) adminCreateTestLoginFlow(w http.ResponseWriter, r *http.Request) {
// The strategy annotates this span with the provider lookup outcome
// (test_login_flow.* attributes); without it, a failed lookup is
// invisible in traces because the whole path is in-memory.
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "login.Handler.adminCreateTestLoginFlow")
defer span.End()
r = r.WithContext(ctx)
var body createTestLoginFlowBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("could not parse request body")))
return
}
if body.ProviderID == "" {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("provider_id is required")))
return
}
if len(body.ProviderID) > maxProviderIDLength {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("provider_id must be at most %d characters", maxProviderIDLength)))
return
}
f, err := NewFlow(h.d, r, flow.TypeBrowser)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
// Admin-created flows have no browser cookie to bind against; mint a
// random UUID as the opaque CSRF bearer and validate it on submit.
f.CSRFToken = x.NewUUID().String()
if err := f.SetTestContext(&TestContext{ProviderID: body.ProviderID}); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
// Mark as "refresh" so the OIDC strategy's alreadyAuthenticated guard does
// not short-circuit the submit if the admin's browser happens to carry an
// unrelated Ory session cookie. Test flows always run the OIDC round-trip
// regardless of session state.
f.Refresh = true
strategy := h.d.TestStrategy(r.Context())
if strategy == nil {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError().WithReason("OIDC strategy is not registered; test login flows cannot be created")))
return
}
if err := strategy.PopulateTestLoginFlow(r, f, body.ProviderID); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().WriteCode(w, r, http.StatusCreated, f)
}
// Delete Test Login Flow Parameters
//
// swagger:parameters deleteTestLoginFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type deleteTestLoginFlow struct {
// ID of the test login flow to delete.
//
// required: true
// in: query
ID string `json:"id"`
// HTTP Cookies. A captured test flow requires the ory_kratos_test_flow
// cookie set by the OIDC callback; a flow still in the initial
// choose-method state does not.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
}
// swagger:route DELETE /self-service/login/test frontend deleteTestLoginFlow
//
// # Delete a test OIDC login flow
//
// Deletes a dry-run OIDC test login flow. A flow whose debug payload has
// been captured requires the HMAC cookie set by the OIDC callback; a flow
// still in the initial choose-method state is deletable with just the flow
// ID (it carries no PII, and the admin may want to abandon it).
//
// Schemes: http, https
//
// Responses:
// 204: emptyResponse
// 400: errorGeneric
// 403: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) deleteTestLoginFlow(w http.ResponseWriter, r *http.Request) {
id, err := uuid.FromString(r.URL.Query().Get("id"))
if err != nil {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("invalid flow id")))
return
}
// Load the flow and discriminate by whether a debug payload has been
// captured. A return of sqlcon.ErrNoRows maps to 404 — we don't leak
// whether the ID belongs to a non-test login flow vs. a missing row.
f, err := h.d.LoginFlowPersister().GetLoginFlow(r.Context(), id)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if !f.IsTest() {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound()))
return
}
if err := f.LoadTestContext(); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if f.TestContext != nil && f.TestContext.DebugPayload != nil &&
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), f.CSRFToken) {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
return
}
if err := h.d.LoginFlowPersister().DeleteTestLoginFlow(r.Context(), id); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,391 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"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/login"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/configx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlxx"
)
// newRegistryWithOIDCProvider returns a fast test registry configured with a
// single generic OIDC provider whose ID is "google". The populator can find
// the provider when the admin-create handler invokes it.
func newRegistryWithOIDCProvider(t *testing.T) *driver.RegistryDefault {
t.Helper()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://../../strategy/oidc/stub/registration.schema.json")),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeOIDC, true)),
configx.WithValue(
config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config.providers",
[]map[string]any{{
"provider": "generic",
"id": "google",
"label": "Google",
"client_id": "invalid",
"client_secret": "invalid",
"issuer_url": "https://foobar/",
"mapper_url": "file://../../strategy/oidc/stub/oidc.facebook.jsonnet",
}},
),
)
return reg
}
func doAdminPost(t *testing.T, adminURL string, body any) (*http.Response, []byte) {
t.Helper()
b, err := json.Marshal(body)
require.NoError(t, err)
res, err := testhelpers.NewTestClient(t).Post(adminURL+"/admin"+login.RouteAdminCreateTestFlow, "application/json", bytes.NewReader(b))
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
respBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, respBody
}
func doDelete(t *testing.T, c *http.Client, u string) (*http.Response, []byte) {
t.Helper()
req, err := http.NewRequest(http.MethodDelete, u, nil)
require.NoError(t, err)
res, err := c.Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
// newSeedTestFlow creates a minimal test login flow directly via the
// persister to support cap-enforcement seeding.
func newSeedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration) *login.Flow {
t.Helper()
f := &login.Flow{
ID: x.NewUUID(),
Type: flow.TypeBrowser,
State: flow.StateChooseMethod,
RequestURL: "https://example.com/self-service/login/browser",
IssuedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(exp),
CSRFToken: nosurfx.FakeCSRFToken,
UI: container.New(""),
InternalContext: sqlxx.JSONRawMessage(`{}`),
}
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
return f
}
func TestHandler_AdminCreateTestLoginFlow_HappyPath(t *testing.T) {
t.Parallel()
reg := newRegistryWithOIDCProvider(t)
_, admin := testhelpers.NewKratosServer(t, reg)
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "id").String())
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
assert.Equal(t, string(flow.StateChooseMethod), gjson.GetBytes(body, "state").String())
// UI contains a provider submit node scoped to google.
found := false
for _, n := range gjson.GetBytes(body, "ui.nodes").Array() {
if n.Get("attributes.name").String() == "provider" && n.Get("attributes.value").String() == "google" {
found = true
break
}
}
assert.True(t, found, "expected provider=google submit node in UI: %s", body)
}
// TestHandler_AdminCreateTestLoginFlow_Tracing asserts that the handler
// emits a span recording which strategy served the request and what the
// provider lookup saw. A production incident where the wrong strategy was
// selected (and thus an empty provider list was searched) was invisible in
// traces because this path had no instrumentation.
func TestHandler_AdminCreateTestLoginFlow_Tracing(t *testing.T) {
t.Parallel()
reg := newRegistryWithOIDCProvider(t)
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
reg.SetTracer(otelx.NewNoop().WithOTLP(provider.Tracer("test")))
_, admin := testhelpers.NewKratosServer(t, reg)
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
res, body = doAdminPost(t, admin.URL, map[string]string{"provider_id": "nonesuch"})
require.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
var spans []sdktrace.ReadOnlySpan
for _, sp := range recorder.Ended() {
if sp.Name() == "login.Handler.adminCreateTestLoginFlow" {
spans = append(spans, sp)
}
}
require.Len(t, spans, 2, "expected one handler span per request")
attrs := func(sp sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
out := make(map[attribute.Key]attribute.Value, len(sp.Attributes()))
for _, kv := range sp.Attributes() {
out[kv.Key] = kv.Value
}
return out
}
found := attrs(spans[0])
assert.Equal(t, "oidc", found["test_login_flow.strategy"].AsString())
assert.Equal(t, int64(1), found["test_login_flow.available_providers"].AsInt64())
assert.True(t, found["test_login_flow.provider_found"].AsBool())
notFound := attrs(spans[1])
assert.Equal(t, "oidc", notFound["test_login_flow.strategy"].AsString())
assert.Equal(t, int64(1), notFound["test_login_flow.available_providers"].AsInt64())
assert.False(t, notFound["test_login_flow.provider_found"].AsBool())
}
func TestHandler_AdminCreateTestLoginFlow_BadInput(t *testing.T) {
t.Parallel()
reg := newRegistryWithOIDCProvider(t)
_, admin := testhelpers.NewKratosServer(t, reg)
for _, tc := range []struct {
name string
body map[string]string
wantStatus int
}{
{name: "empty provider_id", body: map[string]string{}, wantStatus: http.StatusBadRequest},
{name: "unknown provider_id", body: map[string]string{"provider_id": "nonesuch"}, wantStatus: http.StatusNotFound},
{name: "provider_id too long", body: map[string]string{"provider_id": strings.Repeat("a", 256)}, wantStatus: http.StatusBadRequest},
} {
t.Run("case="+tc.name, func(t *testing.T) {
t.Parallel()
res, body := doAdminPost(t, admin.URL, tc.body)
assert.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
})
}
}
func TestHandler_DeleteTestLoginFlow(t *testing.T) {
t.Parallel()
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
}
// seedCapturedMatching: captured flow whose CSRFToken matches what the
// fake CSRF generator returns — simulates the browser that holds the
// post-callback cookie.
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
}
// seedCapturedMismatch: captured flow with a CSRFToken that won't match,
// simulating a request without (or with the wrong) CSRF cookie.
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
}
for _, tc := range []struct {
name string
setup setup
wantStatus int
wantDeleted bool
}{
{
name: "uncaptured: 204",
setup: seedUncaptured,
wantStatus: http.StatusNoContent,
wantDeleted: true,
},
{
name: "captured with mismatched CSRF token: 403",
setup: seedCapturedMismatch,
wantStatus: http.StatusForbidden,
},
{
name: "captured with matching CSRF token: 204",
setup: seedCapturedMatching,
wantStatus: http.StatusNoContent,
wantDeleted: true,
},
} {
t.Run("case="+tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
reg := newRegistryWithOIDCProvider(t)
public, _ := testhelpers.NewKratosServer(t, reg)
f := tc.setup(t, reg)
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
req, err := http.NewRequest(http.MethodDelete, u, nil)
require.NoError(t, err)
res, err := public.Client().Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
_, getErr := reg.LoginFlowPersister().GetLoginFlow(ctx, f.ID)
if tc.wantDeleted {
assert.Error(t, getErr, "flow should be deleted")
} else {
assert.NoError(t, getErr, "flow should still exist")
}
})
}
}
func TestHandler_DeleteTestLoginFlow_NotATestFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
public, _ := testhelpers.NewKratosServer(t, reg)
// Create a regular (non-test) login flow directly.
f := &login.Flow{
ID: x.NewUUID(),
Type: flow.TypeBrowser,
State: flow.StateChooseMethod,
RequestURL: "https://example.com/self-service/login/browser",
IssuedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
CSRFToken: nosurfx.FakeCSRFToken,
UI: container.New(""),
InternalContext: sqlxx.JSONRawMessage(`{}`),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
res, body := doDelete(t, public.Client(), u)
assert.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
}
func TestHandler_DeleteTestLoginFlow_InvalidUUID(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
public, _ := testhelpers.NewKratosServer(t, reg)
u := fmt.Sprintf("%s%s?id=not-a-uuid", public.URL, login.RouteDeleteTestFlow)
res, body := doDelete(t, public.Client(), u)
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", body)
}
func TestHandler_GetTestLoginFlow(t *testing.T) {
t.Parallel()
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
}
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
}
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
}
for _, tc := range []struct {
name string
setup setup
wantStatus int
assertBody func(t *testing.T, body []byte)
}{
{
name: "uncaptured: 200",
setup: seedUncaptured,
wantStatus: http.StatusOK,
assertBody: func(t *testing.T, body []byte) {
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
},
},
{
name: "captured with mismatched CSRF token: 403",
setup: seedCapturedMismatch,
wantStatus: http.StatusForbidden,
},
{
name: "captured with matching CSRF token: 200",
setup: seedCapturedMatching,
wantStatus: http.StatusOK,
assertBody: func(t *testing.T, body []byte) {
assert.Equal(t, "alice@example.com",
gjson.GetBytes(body, "test_context.debug_payload.id_token_claims.sub").String())
},
},
} {
t.Run("case="+tc.name, func(t *testing.T) {
t.Parallel()
reg := newRegistryWithOIDCProvider(t)
public, _ := testhelpers.NewKratosServer(t, reg)
f := tc.setup(t, reg)
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteGetFlow, f.ID)
req, err := http.NewRequest(http.MethodGet, u, nil)
require.NoError(t, err)
res, err := public.Client().Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
if tc.assertBody != nil {
tc.assertBody(t, body)
}
})
}
}
// newSeedCapturedTestFlow creates a test login flow already advanced to the
// passed_challenge state with a DebugPayload populated, simulating what the
// OIDC callback would have persisted. csrfToken is written to f.CSRFToken so
// tests can simulate either the post-callback "matches the cookie" state or a
// mismatch.
func newSeedCapturedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration, csrfToken string) *login.Flow {
t.Helper()
f := newSeedTestFlow(t, ctx, reg, exp)
f.CSRFToken = csrfToken
f.State = flow.StatePassedChallenge
require.NoError(t, f.SetTestContext(&login.TestContext{
ProviderID: "google",
DebugPayload: &login.DebugPayload{
IDTokenClaims: map[string]any{"sub": "alice@example.com"},
},
}))
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(ctx, f))
return f
}

View File

@@ -0,0 +1,20 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package login_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())
}

View File

@@ -0,0 +1,375 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package logout
import (
"net/http"
"net/url"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"go.opentelemetry.io/otel/trace"
"github.com/pkg/errors"
"github.com/ory/kratos/x/events"
"github.com/ory/herodot"
"github.com/ory/x/decoderx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/urlx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
const (
RouteInitBrowserFlow = "/self-service/logout/browser"
RouteAPIFlow = "/self-service/logout/api"
RouteSubmitFlow = "/self-service/logout"
)
type (
dependencies interface {
httpx.WriterProvider
nosurfx.CSRFProvider
session.ManagementProvider
session.PersistenceProvider
errorx.ManagementProvider
config.Provider
}
HandlerProvider interface {
LogoutHandler() *Handler
}
Handler struct{ d dependencies }
)
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterPublicRoutes(router *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteAPIFlow)
router.GET(RouteInitBrowserFlow, h.createBrowserLogoutFlow)
router.DELETE(RouteAPIFlow, h.performNativeLogout)
router.GET(RouteSubmitFlow, h.updateLogoutFlow)
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
admin.DELETE(RouteAPIFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
}
// Logout Flow
//
// swagger:model logoutFlow
type logoutFlow struct {
// LogoutURL can be opened in a browser to sign the user out.
//
// format: uri
// required: true
LogoutURL string `json:"logout_url"`
// LogoutToken can be used to perform logout using AJAX.
//
// required: true
LogoutToken string `json:"logout_token"`
}
// Create Browser Logout Flow Parameters
//
// swagger:parameters createBrowserLogoutFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserLogoutFlow struct {
// HTTP Cookies
//
// If you call this endpoint from a backend, please include the
// original Cookie header in the request.
//
// in: header
// name: cookie
Cookie string `json:"cookie"`
// Return to URL
//
// The URL to which the browser should be redirected to after the logout
// has been performed.
//
// in: query
ReturnTo string `json:"return_to"`
}
// swagger:route GET /self-service/logout/browser frontend createBrowserLogoutFlow
//
// # Create a Logout URL for Browsers
//
// This endpoint initializes a browser-based user logout flow and a URL which can be used to log out the user.
//
// This endpoint is NOT INTENDED for API clients and only works
// with browsers (Chrome, Firefox, ...). For API clients you can
// call the `/self-service/logout/api` URL directly with the Ory Session Token.
//
// The URL is only valid for the currently signed in user. If no user is signed in, this endpoint returns
// a 401 error.
//
// When calling this endpoint from a backend, please ensure to properly forward the HTTP cookies.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: logoutFlow
// 400: errorGeneric
// 401: errorGeneric
// 500: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserLogoutFlow(w http.ResponseWriter, r *http.Request) {
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
conf := h.d.Config()
requestURL := x.RequestURL(r)
var returnTo *url.URL
if requestURL.Query().Get("return_to") != "" {
// Pre-validate the return to URL which is contained in the HTTP request.
returnTo, err = redir.SecureRedirectTo(r,
h.d.Config().SelfServiceFlowLogoutRedirectURL(r.Context()),
redir.SecureRedirectUseSourceURL(requestURL.String()),
redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())),
)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
}
params := url.Values{"token": {sess.LogoutToken}}
if returnTo != nil {
params.Set("return_to", returnTo.String())
}
h.d.Writer().Write(w, r, &logoutFlow{
LogoutToken: sess.LogoutToken,
LogoutURL: urlx.CopyWithQuery(urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteSubmitFlow), params).String(),
})
}
// Perform Native Logout Parameters
//
// swagger:parameters performNativeLogout
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type performNativeLogout struct {
// in: body
// required: true
Body performNativeLogoutBody
}
// Perform Native Logout Request Body
//
// swagger:model performNativeLogoutBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type performNativeLogoutBody struct {
// The Session Token
//
// Invalidate this session token.
//
// required: true
SessionToken string `json:"session_token"`
}
// swagger:route DELETE /self-service/logout/api frontend performNativeLogout
//
// # Perform Logout for Native Apps
//
// Use this endpoint to log out an identity using an Ory Session Token. If the Ory Session Token was successfully
// revoked, the server returns a 204 No Content response. A 204 No Content response is also sent when
// the Ory Session Token has been revoked already before.
//
// If the Ory Session Token is malformed or does not exist a 403 Forbidden response will be returned.
//
// This endpoint does not remove any HTTP
// Cookies - use the Browser-Based Self-Service Logout Flow instead.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 204: emptyResponse
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) performNativeLogout(w http.ResponseWriter, r *http.Request) {
var p performNativeLogoutBody
if err := decoderx.Decode(r, &p,
decoderx.HTTPJSONDecoder(),
decoderx.HTTPDecoderAllowedMethods("DELETE")); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
revoked, err := h.d.SessionPersister().RevokeSessionByToken(r.Context(), p.SessionToken)
if err != nil {
if errors.Is(err, sqlcon.ErrNoRows()) {
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("The provided Ory Session Token could not be found, is invalid, or otherwise malformed.")))
return
}
h.d.Writer().WriteError(w, r, err)
return
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionRevoked(r.Context(), revoked.ID, revoked.IdentityID))
w.WriteHeader(http.StatusNoContent)
}
// Update Logout Flow Parameters
//
// swagger:parameters updateLogoutFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateLogoutFlow struct {
// A Valid Logout Token
//
// If you do not have a logout token because you only have a session cookie,
// call `/self-service/logout/browser` to generate a URL for this endpoint.
//
// in: query
Token string `json:"token"`
// The URL to return to after the logout 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"`
}
// swagger:route GET /self-service/logout frontend updateLogoutFlow
//
// # Update Logout Flow
//
// This endpoint logs out an identity in a self-service manner.
//
// If the `Accept` HTTP header is not set to `application/json`, the browser will be redirected (HTTP 303 See Other)
// to the `return_to` parameter of the initial request or fall back to `urls.default_return_to`.
//
// If the `Accept` HTTP header is set to `application/json`, a 204 No Content response
// will be sent on successful logout instead.
//
// This endpoint is NOT INTENDED for API clients and only works
// with browsers (Chrome, Firefox, ...). For API clients you can
// call the `/self-service/logout/api` URL directly with the Ory Session Token.
//
// More information can be found at [Ory Kratos User Logout Documentation](https://www.ory.com/docs/next/kratos/self-service/flows/user-logout).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 303: emptyResponse
// 204: emptyResponse
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) updateLogoutFlow(w http.ResponseWriter, r *http.Request) {
expected := r.URL.Query().Get("token")
if len(expected) == 0 {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("Please include a token in the URL query.")))
return
}
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
if err != nil {
// We could handle `session.ErrNoActiveSessionFound` gracefully with `h.completeLogout()` here but that would
// actually be an issue as it incorrectly indicates to clients that the session has been removed even if
// `RevokeSessionByToken` has not actually been called.
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
if sess.LogoutToken != expected {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrForbidden().WithReason("Unable to log out because the logout token in the URL query does not match the session cookie.")))
return
}
if err := h.d.SessionManager().PurgeFromRequest(r.Context(), w, r); err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionRevoked(r.Context(), sess.ID, sess.IdentityID))
h.completeLogout(w, r)
}
func (h *Handler) completeLogout(w http.ResponseWriter, r *http.Request) {
_ = h.d.CSRFHandler().RegenerateToken(w, r)
ret, err := redir.SecureRedirectTo(r, h.d.Config().SelfServiceFlowLogoutRedirectURL(r.Context()),
redir.SecureRedirectUseSourceURL(r.RequestURI),
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(r.Context())),
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(r.Context())),
)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
// Instruct the browser to clear cookies, storage, and caches for this origin.
// We only send the header over HTTPS because browsers ignore Clear-Site-Data
// on insecure origins, and gate it behind a config flag (off by default) so we
// do not clear data that other applications on the same origin may rely on.
if h.d.Config().SelfServiceFlowLogoutClearBrowserData(r.Context()) && x.RequestURL(r).Scheme == "https" {
w.Header().Set("Clear-Site-Data", `"cookies", "storage", "cache"`)
}
if x.IsJSONRequest(r) {
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, ret.String(), http.StatusSeeOther)
}

View File

@@ -0,0 +1,74 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package logout_test
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/x"
)
func TestLogoutClearSiteData(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
publicRouter.GET("/session/browser/set", func(w http.ResponseWriter, r *http.Request) {
testhelpers.MockSetSession(t, reg, conf)(w, r)
})
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutBrowserDefaultReturnTo, public.URL+"/")
// Resolve the submit URL and token for a fresh session, then issue the logout
// without following the redirect so we can inspect the 303 response headers.
logout := func(t *testing.T, forwardedProto string) *http.Response {
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
u, err := url.Parse(public.URL + "/self-service/logout/browser")
require.NoError(t, err)
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", u.String(), nil)
require.EqualValues(t, http.StatusOK, res.StatusCode)
logoutURL := gjson.GetBytes(body, "logout_url").String()
require.NotEmpty(t, logoutURL)
hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
req, err := http.NewRequest("GET", logoutURL, nil)
require.NoError(t, err)
if forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", forwardedProto)
}
out, err := hc.Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = out.Body.Close() })
require.Equal(t, http.StatusSeeOther, out.StatusCode, "%s", x.MustReadAll(out.Body))
return out
}
t.Run("case=flag disabled does not set header", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, false)
res := logout(t, "https")
assert.Empty(t, res.Header.Get("Clear-Site-Data"))
})
t.Run("case=flag enabled over https sets header", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, true)
res := logout(t, "https")
assert.Equal(t, `"cookies", "storage", "cache"`, res.Header.Get("Clear-Site-Data"))
})
t.Run("case=flag enabled over http does not set header", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, true)
res := logout(t, "http")
assert.Empty(t, res.Header.Get("Clear-Site-Data"))
})
}

View File

@@ -0,0 +1,305 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package logout_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"testing"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/x"
"github.com/ory/x/urlx"
)
func TestLogout(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
errTS := testhelpers.NewErrorTestServer(t, reg)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
publicRouter.GET("/session/browser/set", func(writer http.ResponseWriter, request *http.Request) {
testhelpers.MockSetSession(t, reg, conf)(writer, request)
})
publicRouter.GET("/session/browser/get", func(w http.ResponseWriter, r *http.Request) {
sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
if err != nil {
reg.Writer().WriteError(w, r, err)
return
}
reg.Writer().Write(w, r, sess)
})
publicRouter.POST("/csrf/check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutBrowserDefaultReturnTo, public.URL+"/session/browser/get")
t.Run("case=successful logout for API clients", func(t *testing.T) {
hc := testhelpers.NewDebugClient(t)
sess := testhelpers.CreateSession(t, reg)
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "`+sess.Token+`"}`))
assert.Equal(t, http.StatusNoContent, res.StatusCode)
actual, err := reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing)
require.NoError(t, err)
assert.False(t, actual.IsActive())
// Retrying logout should not fail:
_, res = testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "`+sess.Token+`"}`))
assert.Equal(t, http.StatusNoContent, res.StatusCode)
})
t.Run("case=unsuccessful logout for API clients because session token is invalid", func(t *testing.T) {
hc := testhelpers.NewDebugClient(t)
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "not a valid token"}`))
assert.Equal(t, http.StatusForbidden, res.StatusCode)
})
t.Run("case=unsuccessful logout for API clients because session token is missing", func(t *testing.T) {
hc := testhelpers.NewDebugClient(t)
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{}`))
assert.Equal(t, http.StatusForbidden, res.StatusCode)
})
makeBrowserLogout := func(t *testing.T, hc *http.Client, u string) ([]byte, *http.Response) {
res, err := hc.Get(u)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
return x.MustReadAll(res.Body), res
}
getLogoutUrl := func(t *testing.T, params url.Values) (hc *http.Client, logoutUrl string) {
hc = testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
u, err := url.Parse(public.URL)
require.NoError(t, err)
u.Path = "/self-service/logout/browser"
if params != nil {
u.RawQuery = params.Encode()
}
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", u.String(), nil)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
logoutUrl = gjson.GetBytes(body, "logout_url").String()
logoutToken := gjson.GetBytes(body, "logout_token").String()
logoutUrlParsed, err := url.Parse(logoutUrl)
require.NoError(t, err)
assert.Equalf(t, logoutUrlParsed.Query().Get("token"), logoutToken, "%s", body)
assert.Equalf(t, logoutUrlParsed.Path, "/self-service/logout", "%s", body)
return
}
t.Run("case=successful logout for browser clients", func(t *testing.T) {
ensurePrincipalChange := func(t *testing.T, originalCookies []*http.Cookie) {
cj, err := cookiejar.New(nil)
require.NoError(t, err)
cj.SetCookies(urlx.ParseOrPanic(public.URL), originalCookies)
res, err := (&http.Client{Jar: cj, Transport: testhelpers.NewTestTransport(t)}).PostForm(public.URL+"/csrf/check", url.Values{})
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.EqualValues(t, http.StatusForbidden, res.StatusCode)
body := x.MustReadAll(res.Body)
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
hc, logoutUrl := getLogoutUrl(t, nil)
originalCookies := hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
body, res := makeBrowserLogout(t, hc, logoutUrl)
assert.EqualValues(t, public.URL+"/session/browser/get", res.Request.URL.String())
require.NotContains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String())
// Logout means CSRF has also been changed.
ensurePrincipalChange(t, originalCookies)
})
t.Run("type=ajax", func(t *testing.T) {
hc, logoutUrl := getLogoutUrl(t, nil)
originalCookies := hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", logoutUrl, nil)
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
assert.EqualValues(t, http.StatusNoContent, res.StatusCode, "%s", body)
require.NotContains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
// Logout means CSRF has also been changed.
ensurePrincipalChange(t, originalCookies)
})
})
t.Run("case=calling submission without token but with session", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
body, res := makeBrowserLogout(t, hc, public.URL+"/self-service/logout")
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, "Please include a token in the URL query.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("type=ajax", func(t *testing.T) {
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", public.URL+"/self-service/logout", nil)
assert.EqualValues(t, public.URL+"/self-service/logout", res.Request.URL.String())
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode, "%s", body)
assert.EqualValues(t, "Please include a token in the URL query.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
})
t.Run("case=calling submission with token but without session", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
_, logoutUrl := getLogoutUrl(t, nil)
body, res := makeBrowserLogout(t, testhelpers.NewTestClient(t), logoutUrl)
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("type=ajax", func(t *testing.T) {
_, logoutUrl := getLogoutUrl(t, nil)
body, res := testhelpers.HTTPRequestJSON(t, testhelpers.NewTestClient(t), "GET", logoutUrl, nil)
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
})
t.Run("case=calling submission with token but with session from another user", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
otherUser := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
_, logoutUrl := getLogoutUrl(t, nil)
body, res := makeBrowserLogout(t, otherUser, logoutUrl)
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("type=ajax", func(t *testing.T) {
otherUser := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
_, logoutUrl := getLogoutUrl(t, nil)
body, res := testhelpers.HTTPRequestJSON(t, otherUser, "GET", logoutUrl, nil)
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
assert.EqualValues(t, http.StatusForbidden, res.StatusCode, "%s", body)
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
})
t.Run("case=calling submission with invalid token", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
hc, logoutUrl := getLogoutUrl(t, nil)
body, res := makeBrowserLogout(t, hc, logoutUrl+"invalid")
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("type=ajax", func(t *testing.T) {
hc, logoutUrl := getLogoutUrl(t, nil)
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", logoutUrl+"invalid", nil)
assert.EqualValues(t, logoutUrl+"invalid", res.Request.URL.String())
assert.EqualValues(t, http.StatusForbidden, res.StatusCode, "%s", body)
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
})
t.Run("case=calling browser init without session", func(t *testing.T) {
body, res := testhelpers.HTTPRequestJSON(t, testhelpers.NewTestClient(t), "GET", public.URL+"/self-service/logout/browser", nil)
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=init logout through browser does 303 redirect", func(t *testing.T) {
// init the logout
hc, logoutUrl := getLogoutUrl(t, nil)
// prevent the redirect, so we can get check the status code
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
// submit the login
req, err := http.NewRequest("GET", logoutUrl, nil)
require.NoError(t, err)
res, err := hc.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)
})
t.Run("case=init logout with return_to should carry over return_to", func(t *testing.T) {
reg.Config().MustSet(context.Background(), config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.sh"})
hc, logoutUrl := getLogoutUrl(t, url.Values{"return_to": {"https://www.ory.sh"}})
logoutUrlParsed, err := url.Parse(logoutUrl)
require.NoError(t, err)
assert.Equal(t, "https://www.ory.sh", logoutUrlParsed.Query().Get("return_to"))
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
body, res := makeBrowserLogout(t, hc, logoutUrl)
assert.EqualValues(t, http.StatusSeeOther, res.StatusCode, "%s", body)
assert.EqualValues(t, "https://www.ory.sh", res.Header.Get("Location"))
})
t.Run("case=init logout with return_to should not carry over return_to if not allowed", func(t *testing.T) {
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
logoutUrl := public.URL + "/self-service/logout/browser?return_to=https://www.ory.com"
r, err := http.NewRequest("GET", logoutUrl, nil)
require.NoError(t, err)
r.Header.Set("Accept", "application/json")
resp, err := hc.Do(r)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.EqualValues(t, "Requested return_to URL \"https://www.ory.com\" is not allowed.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
assert.EqualValues(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
})
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}
}
}

28
selfservice/flow/name.go Normal file
View File

@@ -0,0 +1,28 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
// FlowName is the flow name.
//
// The flow name can be one of:
// - 'login'
// - 'registration'
// - 'settings'
// - 'recovery'
// - 'verification'
//
// swagger:ignore
type FlowName string
const (
LoginFlow FlowName = "login"
RegistrationFlow FlowName = "registration"
SettingsFlow FlowName = "settings"
RecoveryFlow FlowName = "recovery"
VerificationFlow FlowName = "verification"
)
func (t Type) String() string {
return string(t)
}

View File

@@ -0,0 +1,26 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"net/http"
"github.com/ory/kratos/x/nosurfx"
)
func GetCSRFToken(reg interface {
nosurfx.CSRFProvider
nosurfx.CSRFTokenGeneratorProvider
}, w http.ResponseWriter, r *http.Request, p Type) string {
token := reg.GenerateCSRFToken(r)
if p != TypeBrowser {
return token
}
if token == "" {
return reg.CSRFHandler().RegenerateToken(w, r)
}
return token
}

View File

@@ -0,0 +1,59 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/nosurf"
)
func TestGetCSRFToken(t *testing.T) {
noToken := &mockReg{
presentToken: "",
regeneratedToken: "regenerated",
}
tokenPresent := &mockReg{
presentToken: "existing",
regeneratedToken: "regenerated",
}
t.Run("case=no token, browser flow", func(t *testing.T) {
assert.Equal(t, "regenerated", GetCSRFToken(noToken, nil, nil, TypeBrowser))
})
t.Run("case=token present, browser flow", func(t *testing.T) {
assert.Equal(t, "existing", GetCSRFToken(tokenPresent, nil, nil, TypeBrowser))
})
t.Run("case=no token, api flow", func(t *testing.T) {
assert.Equal(t, "", GetCSRFToken(noToken, nil, nil, TypeAPI))
})
t.Run("case=token present, api flow", func(t *testing.T) {
assert.Equal(t, "existing", GetCSRFToken(tokenPresent, nil, nil, TypeAPI))
})
}
type mockReg struct {
presentToken, regeneratedToken string
nosurf.Handler
}
func (m *mockReg) GenerateCSRFToken(*http.Request) string {
return m.presentToken
}
func (m *mockReg) CSRFHandler() nosurf.Handler {
return m
}
func (m *mockReg) RegenerateToken(http.ResponseWriter, *http.Request) string {
return m.regeneratedToken
}

View File

@@ -0,0 +1,32 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"net/url"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// ParseOrganizationFromURLQuery is a helper function that parses the organization ID for a self-service flow from
// the URL query parameters. If the organization ID is not found in the URL query parameters, the function will return
// an NULL UUID.
func ParseOrganizationFromURLQuery(ctx context.Context, q url.Values) (orgID uuid.NullUUID) {
if rawOrg := q.Get("organization"); rawOrg != "" {
orgIDFromURL, err := uuid.FromString(rawOrg)
if err != nil {
trace.SpanFromContext(ctx).RecordError(err, trace.WithAttributes(attribute.String("organization", rawOrg)))
} else {
orgID = uuid.NullUUID{UUID: orgIDFromURL, Valid: true}
}
}
return
}
type OrganizationImplementor interface {
SupportsOrganizations() bool
}

View File

@@ -0,0 +1,45 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
)
func TestParseOrganizationFromURLQuery(t *testing.T) {
tests := []struct {
name string
query url.Values
expected uuid.NullUUID
}{
{
name: "valid organization ID",
query: url.Values{"organization": {"123e4567-e89b-12d3-a456-426614174000"}},
expected: uuid.NullUUID{UUID: uuid.FromStringOrNil("123e4567-e89b-12d3-a456-426614174000"), Valid: true},
},
{
name: "invalid organization ID",
query: url.Values{"organization": {"invalid-uuid"}},
expected: uuid.NullUUID{Valid: false},
},
{
name: "missing organization ID",
query: url.Values{},
expected: uuid.NullUUID{Valid: false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
result := ParseOrganizationFromURLQuery(ctx, tt.query)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -0,0 +1,72 @@
{
"type": "api",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,6 @@
{
"code": 400,
"message": "The request was malformed or contained invalid parameters",
"reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"status": "Bad Request"
}

View File

@@ -0,0 +1,72 @@
{
"type": "browser",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,72 @@
{
"type": "api",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,6 @@
{
"code": 400,
"message": "The request was malformed or contained invalid parameters",
"reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"status": "Bad Request"
}

View File

@@ -0,0 +1,72 @@
{
"type": "browser",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,187 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"net/http"
"net/url"
"github.com/ory/kratos/x/nosurfx"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/sqlxx"
"github.com/ory/kratos/ui/node"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/x/urlx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
)
var ErrHookAbortFlow = errors.New("aborted recovery hook execution")
func ErrAlreadyLoggedIn() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDAlreadyLoggedIn).WithReason("A valid session was detected and thus recovery is not possible.")
}
type (
errorHandlerDependencies interface {
clock.Provider
errorx.ManagementProvider
httpx.WriterProvider
logrusx.Provider
nosurfx.CSRFTokenGeneratorProvider
config.Provider
StrategyProvider
FlowPersistenceProvider
}
ErrorHandlerProvider interface {
RecoveryFlowErrorHandler() *ErrorHandler
}
ErrorHandler struct {
d errorHandlerDependencies
}
)
func NewErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}
func (s *ErrorHandler) WriteFlowError(
w http.ResponseWriter,
r *http.Request,
f *Flow,
group node.UiNodeGroup,
recoveryErr error,
) {
logger := s.d.Logger().
WithError(recoveryErr).
WithRequest(r).
WithField("recovery_flow", f.ToLoggerField())
logger.
Info("Encountered self-service recovery error.")
if f == nil {
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), uuid.Nil, "", "", recoveryErr))
s.forward(w, r, nil, recoveryErr)
return
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), f.ID, string(f.Type), f.Active.String(), recoveryErr))
if expiredError := new(flow.ExpiredError); errors.As(recoveryErr, &expiredError) {
strategies, _, err := s.d.RecoveryStrategies(r.Context()).ActiveStrategies(f.Active.String())
if err != nil {
strategies, _, err = s.d.GetActiveRecoveryStrategies(r.Context())
// Can't retry the recovery if no primary strategy has been set
if err != nil {
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
}
// create new flow because the old one is not valid
newFlow, err := FromOldFlow(s.d, s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.GenerateCSRFToken(r), r, strategies, *f)
if err != nil {
// failed to create a new session and redirect to it, handle that error as a new one
s.WriteFlowError(w, r, f, group, err)
return
}
newFlow.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(s.d.Clock(), expiredError.ExpiredAt))
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), newFlow); err != nil {
s.forward(w, r, newFlow, err)
return
}
if s.d.Config().UseContinueWithTransitions(r.Context()) {
switch {
case newFlow.Type.IsAPI():
expiredError.FlowID = newFlow.ID
s.d.Writer().WriteError(w, r, expiredError.WithContinueWith(flow.NewContinueWithRecoveryUI(newFlow)))
case x.IsJSONRequest(r):
http.Redirect(w, r, urlx.CopyWithQuery(
urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow),
url.Values{"id": {newFlow.ID.String()}},
).String(), http.StatusSeeOther)
default:
http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
}
} else {
trace.SpanFromContext(r.Context()).AddEvent(semconv.NewDeprecatedFeatureUsedEvent(r.Context(), "no_continue_with_transition_recovery_error_handler"))
// We need to use the new flow, as that flow will be a browser flow. Bug fix for:
//
// https://github.com/ory/kratos/issues/2049!!
if newFlow.Type == flow.TypeAPI || x.IsJSONRequest(r) {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()),
RouteGetFlow), url.Values{"id": {newFlow.ID.String()}}).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
}
}
return
}
f.UI.ResetMessages()
if err := f.UI.ParseError(group, recoveryErr); err != nil {
s.forward(w, r, f, err)
return
}
f.Active = sqlxx.NullString(group)
if err := s.d.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil {
s.forward(w, r, f, err)
return
}
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
return
}
updatedFlow, innerErr := s.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), f.ID)
if innerErr != nil {
s.forward(w, r, updatedFlow, innerErr)
return
}
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(recoveryErr, http.StatusBadRequest), updatedFlow)
}
func (s *ErrorHandler) forward(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(r.Context(), 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(r.Context(), w, r, err)
}
}

View File

@@ -0,0 +1,571 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/clock"
"github.com/ory/x/configx"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"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/x/assertx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/jsonx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/urlx"
)
func TestHandleError(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeySelfServiceRecoveryEnabled: true,
config.ViperKeySelfServiceRecoveryUse: "code",
}),
)
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
testhelpers.NewErrorTestServer(t, reg)
h := reg.RecoveryFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var recoveryFlow *recovery.Flow
var flowError error
var methodName node.UiNodeGroup
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(w, r, recoveryFlow, methodName, flowError)
})
reset := func() {
recoveryFlow = nil
flowError = nil
methodName = ""
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
s, _, err := reg.GetActiveRecoveryStrategies(context.Background())
require.NoError(t, err)
f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f))
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID)
require.NoError(t, err)
return f
}
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
require.NoError(t, err)
return sse.Error, nil
}
anHourAgo := time.Now().Add(-time.Hour)
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow)
require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body))
assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
})
t.Run("case=fails if active strategy is disabled", func(t *testing.T) {
c, reg := pkg.NewVeryFastRegistryWithoutDB(t)
require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false))
require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code"))
_, _, err := reg.GetActiveRecoveryStrategies(context.Background())
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = err
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value"))
})
})
}
t.Run("flow=browser", func(t *testing.T) {
expectRecoveryUI := func(t *testing.T) (*recovery.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.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=")
rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return rf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(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)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.LinkGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=new flow uses strategy of old flow", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String())
})
t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, "code", lf.Active.String())
})
t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) {
t.Cleanup(func() {
reset()
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code")
})
recoveryFlow = newFlow(t, 0, flow.TypeBrowser)
recoveryFlow.Active = "not-valid"
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid")
sse, _ := expectErrorUI(t)
snapshotx.SnapshotT(t, sse)
})
})
}
func TestHandleError_WithContinueWith(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeyUseContinueWithTransitions: true,
config.ViperKeySelfServiceRecoveryEnabled: true,
config.ViperKeySelfServiceRecoveryUse: "code",
}),
)
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
testhelpers.NewErrorTestServer(t, reg)
h := reg.RecoveryFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var recoveryFlow *recovery.Flow
var flowError error
var methodName node.UiNodeGroup
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(w, r, recoveryFlow, methodName, flowError)
})
reset := func() {
recoveryFlow = nil
flowError = nil
methodName = ""
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
s, _, err := reg.GetActiveRecoveryStrategies(context.Background())
require.NoError(t, err)
f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f))
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID)
require.NoError(t, err)
return f
}
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
require.NoError(t, err)
return sse.Error, nil
}
anHourAgo := time.Now().Add(-time.Hour)
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
switch tc.t {
case flow.TypeAPI:
require.Equal(t, http.StatusGone, res.StatusCode, "%s", body)
require.Len(t, gjson.GetBytes(body, "error.details.continue_with").Array(), 1, "%s", body)
require.Equal(t, "show_recovery_ui", gjson.GetBytes(body, "error.details.continue_with.0.action").String(), "%s", body)
id := gjson.GetBytes(body, "error.details.continue_with.0.flow.id").String()
res, err = ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, public.URL+recovery.RouteGetFlow+"?id="+id))
require.NoError(t, err)
body = ioutilx.MustReadAll(res.Body)
case flow.TypeBrowser:
require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow, "%s", body)
require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request)
}
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body))
assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
})
t.Run("case=fails if active strategy is disabled", func(t *testing.T) {
c, reg := pkg.NewVeryFastRegistryWithoutDB(t)
require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false))
require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code"))
_, _, err := reg.GetActiveRecoveryStrategies(context.Background())
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = err
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
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)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value"))
})
})
}
t.Run("flow=browser", func(t *testing.T) {
expectRecoveryUI := func(t *testing.T) (*recovery.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.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=")
rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return rf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(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)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.LinkGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=new flow uses strategy of old flow", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String())
})
t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, "code", lf.Active.String())
})
t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) {
t.Cleanup(func() {
reset()
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code")
})
recoveryFlow = newFlow(t, 0, flow.TypeBrowser)
recoveryFlow.Active = "not-valid"
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid")
sse, _ := expectErrorUI(t)
snapshotx.SnapshotT(t, sse)
})
})
}

View File

@@ -0,0 +1,334 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/redir"
"github.com/ory/pop/v6"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/clock"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
// A Recovery Flow
//
// This request is used when an identity wants to recover their account.
//
// We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery)
//
// swagger:model recoveryFlow
type Flow struct {
// ID represents the request's unique ID. When performing the recovery flow, this
// represents the id in the recovery ui's query parameter: http://<selfservice.flows.recovery.ui_url>?request=<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 request expires. If the user still wishes to update the setting,
// a new request 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 request 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"`
// InternalContext stores internal-only data for this flow.
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
// ReturnTo contains the requested return_to URL.
ReturnTo string `json:"return_to,omitempty" db:"-"`
// Active, if set, contains the recovery method that is being used. It is initially
// not set.
Active sqlxx.NullString `json:"active,omitempty" faker:"-" db:"active_method"`
// UI contains data which must be shown in the user interface.
//
// required: true
UI *container.Container `json:"ui" db:"ui"`
// State represents the state of this request:
//
// - choose_method: ask the user to choose a method (e.g. recover account via email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the recovery challenge was passed.
//
// required: true
State State `json:"state" faker:"-" db:"state"`
// CSRFToken contains the anti-csrf token associated with this request.
CSRFToken string `json:"-" db:"csrf_token"`
// 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"`
// RecoveredIdentityID is a helper struct field for gobuffalo.pop.
RecoveredIdentityID uuid.NullUUID `json:"-" faker:"-" db:"recovered_identity_id"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
// DangerousSkipCSRFCheck indicates whether anti CSRF measures should be enforced in this flow
//
// This is needed, because we can not enforce these measures, if the flow has been initialized by someone else than
// the user.
DangerousSkipCSRFCheck bool `json:"-" faker:"-" db:"skip_csrf_check"`
// Contains possible actions that could follow this flow
ContinueWith []flow.ContinueWith `json:"continue_with,omitempty" faker:"-" db:"-"`
// TransientPayload is used to pass data from the recovery flow to hooks and email templates
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
}
var _ flow.Flow = (*Flow)(nil)
// flowDependencies are the dependencies NewFlow needs to construct a recovery
// flow: the configuration (for return-to validation) and the clock. The
// lifespan and CSRF token are passed explicitly because callers vary them
// (token-based lifespans, regenerated CSRF tokens).
type flowDependencies interface {
config.Provider
clock.Provider
}
func NewFlow(reg flowDependencies, exp time.Duration, csrf string, r *http.Request, strategies Strategies, ft flow.Type) (*Flow, error) {
conf := reg.Config()
now := reg.Clock().Now().UTC()
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
}
state := flow.StateChooseMethod
if conf.ChooseRecoveryAddress(r.Context()) {
state = flow.StateRecoveryAwaitingAddress
} else {
trace.SpanFromContext(r.Context()).AddEvent(semconv.NewDeprecatedFeatureUsedEvent(r.Context(), "legacy_recovery_flow"))
}
f := &Flow{
ID: id,
ExpiresAt: now.Add(exp),
IssuedAt: now,
RequestURL: requestURL,
UI: &container.Container{
Method: "POST",
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
},
State: state,
CSRFToken: csrf,
Type: ft,
}
if err := f.setCourierBaseURL(x.BaseURLStringFromContext(r.Context())); err != nil {
return nil, err
}
for _, strategy := range strategies {
if strategy.IsPrimary() {
f.Active = sqlxx.NullString(strategy.NodeGroup())
}
if err := strategy.PopulateRecoveryMethod(r, f); err != nil {
return nil, err
}
}
return f, nil
}
func FromOldFlow(reg flowDependencies, exp time.Duration, csrf string, r *http.Request, strategies Strategies, of Flow) (*Flow, error) {
f := of.Type
// Using the same flow in the recovery/verification context can lead to using API flow in a verification/recovery email
if of.Type == flow.TypeAPI && of.Active.String() == string(RecoveryStrategyLink) {
f = flow.TypeBrowser
}
nf, err := NewFlow(reg, exp, csrf, r, strategies, f)
if err != nil {
return nil, err
}
nf.RequestURL = of.RequestURL
return nf, nil
}
func (f *Flow) GetType() flow.Type { return f.Type }
func (f *Flow) GetRequestURL() string { return f.RequestURL }
func (Flow) TableName() string { return "selfservice_recovery_flows" }
func (f Flow) GetID() uuid.UUID { return f.ID }
func (f *Flow) GetUI() *container.Container { return f.UI }
func (f *Flow) GetState() State { return f.State }
func (Flow) GetFlowName() flow.FlowName { return flow.RecoveryFlow }
func (f *Flow) SetState(state State) { f.State = state }
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
func (f *Flow) SetInternalContext(c sqlxx.JSONRawMessage) { f.InternalContext = c }
// EnsureInternalContext initializes InternalContext to an empty JSON object
// if it is missing or not valid JSON. Mirrors the registration / settings /
// login flow implementations.
func (f *Flow) EnsureInternalContext() {
if !gjson.ValidBytes(f.InternalContext) {
f.InternalContext = []byte("{}")
}
}
// GetCourierBaseURL returns the base URL captured at flow init from the
// request context, or the empty string when nothing was captured (the email
// senders then fall back to Config.SelfPublicURL).
func (f *Flow) GetCourierBaseURL() string {
return gjson.GetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL).String()
}
// setCourierBaseURL writes the captured base URL into InternalContext under
// the well-known key. Empty input is a no-op (preserves the fall-back
// path). Inputs longer than 8192 bytes are rejected — the same implicit
// ceiling the dedicated VARCHAR(8192) column used to enforce — so a
// pathological header cannot bloat the row.
func (f *Flow) setCourierBaseURL(s string) error {
if s == "" {
return nil
}
if len(s) > 8192 {
return nil
}
f.EnsureInternalContext()
out, err := sjson.SetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL, s)
if err != nil {
return errors.WithStack(err)
}
f.InternalContext = out
return nil
}
// ShouldSkipSettingsFlow flow returns true iff. `skip_settings` was requested
// in the URL query parameters.
func (f *Flow) ShouldSkipSettingsFlow() bool {
u, err := url.Parse(f.RequestURL)
if err != nil {
return false
}
shouldSkip, _ := strconv.ParseBool(u.Query().Get("skip_settings"))
return shouldSkip
}
// SetShouldSkipSettingsFlow encodes shouldSkip in the flow's request URL. The
// setting can be retreived with `f.ShouldSkipSettingsFlow`
func (f *Flow) SetShouldSkipSettingsFlow(shouldSkip bool) error {
reqUrl, err := url.Parse(f.RequestURL)
if err != nil {
return errors.WithStack(err)
}
q := reqUrl.Query()
q.Set("skip_settings", strconv.FormatBool(shouldSkip))
reqUrl.RawQuery = q.Encode()
f.RequestURL = reqUrl.String()
return nil
}
func (f *Flow) Valid(c clock.Clock) error {
if f.ExpiresAt.Before(c.Now().UTC()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
return nil
}
func (f *Flow) AppendTo(src *url.URL) *url.URL {
return urlx.CopyWithQuery(src, url.Values{"flow": {f.ID.String()}})
}
func (f *Flow) SetCSRFToken(token string) {
f.CSRFToken = token
f.UI.SetCSRF(token)
}
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) ToLoggerField() map[string]any {
if f == nil {
return map[string]any{}
}
return map[string]any{
"id": f.ID.String(),
"return_to": f.ReturnTo,
"request_url": f.RequestURL,
"active": f.Active,
"type": f.Type,
"nid": f.NID,
"state": f.State,
}
}

View File

@@ -0,0 +1,212 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy/code"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/x"
"github.com/ory/x/clock"
"github.com/ory/x/jsonx"
"github.com/ory/x/urlx"
)
func TestFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
must := func(r *recovery.Flow, err error) *recovery.Flow {
require.NoError(t, err)
return r
}
u := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"}
for k, tc := range []struct {
r *recovery.Flow
expectErr bool
}{
{r: must(recovery.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser))},
{r: must(recovery.NewFlow(reg, -time.Hour, "", u, nil, flow.TypeBrowser)), expectErr: true},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
err := tc.r.Valid(clock.New())
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
assert.EqualValues(t, flow.StateChooseMethod,
must(recovery.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser)).State)
t.Run("type=return_to", func(t *testing.T) {
_, err := recovery.NewFlow(reg, 0, "csrf", &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, nil, flow.TypeBrowser)
require.Error(t, err)
_, err = recovery.NewFlow(reg, 0, "csrf", &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, nil, flow.TypeBrowser)
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 := &recovery.Flow{Type: ft}
assert.Equal(t, ft, r.GetType())
})
}
}
func TestGetRequestURL(t *testing.T) {
expectedURL := "http://foo/bar/baz"
f := &recovery.Flow{RequestURL: expectedURL}
assert.Equal(t, expectedURL, f.GetRequestURL())
}
func TestNewFlow_capturesCourierBaseURL(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
t.Run("nothing in context", func(t *testing.T) {
r := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "", f.GetCourierBaseURL())
})
t.Run("BaseURL in request context is copied onto flow", func(t *testing.T) {
base := urlx.ParseOrPanic("https://customer.example.com/")
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), base))
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, base.String(), f.GetCourierBaseURL())
})
t.Run("http scheme is preserved (not coerced to https)", func(t *testing.T) {
base := urlx.ParseOrPanic("http://localhost:4000")
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), base))
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "http://localhost:4000", f.GetCourierBaseURL())
})
}
func TestFlowEncodeJSON(t *testing.T) {
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &recovery.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
}
func TestFromOldFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
r := http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}
t.Run("strategy=code", func(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) {
f, err := recovery.NewFlow(reg, 0, "csrf", &r, recovery.Strategies{code.NewStrategy(reg)}, ft)
require.NoError(t, err)
nF, err := recovery.FromOldFlow(reg, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f)
require.NoError(t, err)
require.Equal(t, ft, nF.Type)
})
}
})
t.Run("strategy=link", func(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) {
f, err := recovery.NewFlow(reg, 0, "csrf", &r, recovery.Strategies{link.NewStrategy(reg)}, ft)
require.NoError(t, err)
nF, err := recovery.FromOldFlow(reg, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f)
require.NoError(t, err)
require.Equal(t, flow.TypeBrowser, nF.Type)
})
}
})
}
func TestFromOldFlow_capturesCourierBaseURL(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
t.Run("uses the new request's context, not the old flow's value", func(t *testing.T) {
// Old flow was created at customer.example.com.
oldR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
oldF, err := recovery.NewFlow(reg, time.Hour, "csrf", oldR, nil, flow.TypeBrowser)
require.NoError(t, err)
require.Equal(t, "https://customer.example.com/", oldF.GetCourierBaseURL())
// New request comes through a different proxy / CNAME — we should
// reflect that, not silently inherit the old flow's captured URL.
newR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("http://localhost:4000")))
newF, err := recovery.FromOldFlow(reg, time.Hour, oldF.CSRFToken, newR, nil, *oldF)
require.NoError(t, err)
assert.Equal(t, "http://localhost:4000", newF.GetCourierBaseURL())
})
t.Run("empty when neither old nor new have a base URL", func(t *testing.T) {
r := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}
oldF, err := recovery.NewFlow(reg, time.Hour, "csrf", r, nil, flow.TypeBrowser)
require.NoError(t, err)
newF, err := recovery.FromOldFlow(reg, time.Hour, oldF.CSRFToken, r, nil, *oldF)
require.NoError(t, err)
assert.Equal(t, "", newF.GetCourierBaseURL())
})
}
// TestCourierBaseURLStoredInInternalContext locks down the storage
// location: the captured base URL must land in InternalContext under
// flow.InternalContextKeyCourierBaseURL, not in a dedicated column. If a
// future refactor moves it elsewhere, this fails — separately from the
// getter contract.
func TestCourierBaseURLStoredInInternalContext(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
f, err := recovery.NewFlow(reg, time.Hour, "csrf", r, nil, flow.TypeBrowser)
require.NoError(t, err)
got := gjson.GetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL).String()
assert.Equal(t, "https://customer.example.com/", got)
}
func TestFlowDontOverrideReturnTo(t *testing.T) {
f := &recovery.Flow{ReturnTo: "/foo"}
f.SetReturnTo()
assert.Equal(t, "/foo", f.ReturnTo)
f = &recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
f.SetReturnTo()
assert.Equal(t, "/bar", f.ReturnTo)
}

View File

@@ -0,0 +1,480 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"net/http"
"github.com/pkg/errors"
"github.com/ory/herodot"
"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/session"
"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/sqlcon"
"github.com/ory/x/urlx"
)
const (
RouteInitBrowserFlow = "/self-service/recovery/browser"
RouteInitAPIFlow = "/self-service/recovery/api"
RouteGetFlow = "/self-service/recovery/flows"
RouteSubmitFlow = "/self-service/recovery"
)
type (
HandlerProvider interface {
RecoveryHandler() *Handler
}
handlerDependencies interface {
clock.Provider
errorx.ManagementProvider
identity.ManagementProvider
identity.PrivilegedPoolProvider
session.HandlerProvider
StrategyProvider
FlowPersistenceProvider
nosurfx.CSRFTokenGeneratorProvider
httpx.WriterProvider
nosurfx.CSRFProvider
config.Provider
ErrorHandlerProvider
HookExecutorProvider
}
Handler struct {
d handlerDependencies
}
)
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
redirect := session.RedirectOnAuthenticated(h.d)
public.GET(RouteInitBrowserFlow, h.d.SessionHandler().IsNotAuthenticated(h.createBrowserRecoveryFlow, func(w http.ResponseWriter, r *http.Request) {
if x.IsJSONRequest(r) {
h.d.Writer().WriteError(w, r, errors.WithStack(ErrAlreadyLoggedIn()))
} else {
redirect(w, r)
}
}))
public.GET(RouteInitAPIFlow, h.d.SessionHandler().IsNotAuthenticated(h.createNativeRecoveryFlow,
session.RespondWithJSONErrorOnAuthenticated(h.d.Writer(), ErrAlreadyLoggedIn())))
public.GET(RouteGetFlow, h.getRecoveryFlow)
public.GET(RouteSubmitFlow, h.updateRecoveryFlow)
public.POST(RouteSubmitFlow, h.updateRecoveryFlow)
}
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.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
admin.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
}
// swagger:route GET /self-service/recovery/api frontend createNativeRecoveryFlow
//
// # Create Recovery Flow for Native Apps
//
// This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.
//
// If a valid provided session cookie or session token is provided, a 400 Bad Request error.
//
// On an existing recovery flow, use the `getRecoveryFlow` API endpoint.
//
// 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.
//
// 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 Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createNativeRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
activeRecoveryStrategies, _, err := h.d.GetActiveRecoveryStrategies(r.Context())
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
f, err := NewFlow(h.d, h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategies, flow.TypeAPI)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, f)
}
// Create Browser Recovery Flow Parameters
//
// swagger:parameters createBrowserRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserRecoveryFlow struct {
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// Skip redirection to the settings UI after the recovery flow was
// completed. Instead, the user will be redirected to the URL specified in
// `return_to` query parameter or the default return URL if `return_to` is
// not set.
//
// in: query
SkipSettings string `json:"skip_settings"`
}
// swagger:route GET /self-service/recovery/browser frontend createBrowserRecoveryFlow
//
// # Create Recovery Flow for Browsers
//
// This endpoint initializes a browser-based account recovery flow. Once initialized, the browser will be redirected to
// `selfservice.flows.recovery.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
// exists, the browser is returned to the configured return URL.
//
// If this endpoint is called via an AJAX request, the response contains the recovery flow without any redirects
// or a 400 bad request error if the user is already authenticated.
//
// 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 Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 303: emptyResponse
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
activeRecoveryStrategies, _, err := h.d.GetActiveRecoveryStrategies(r.Context())
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
f, err := NewFlow(h.d, h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategies, flow.TypeBrowser)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
redirTo := f.AppendTo(h.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String()
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), f, redirTo)
}
// Get Recovery Flow Parameters
//
// swagger:parameters getRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getRecoveryFlow struct {
// The Flow ID
//
// The value for this parameter comes from `request` URL Query parameter sent to your
// application (e.g. `/recovery?flow=abcde`).
//
// required: true
// in: query
FlowID string `json:"id"`
// 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/recovery/flows frontend getRecoveryFlow
//
// # Get Recovery Flow
//
// This endpoint returns a recovery flow's context with, for example, error details and other information.
//
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
//
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
// and you need to forward the incoming HTTP Cookie header to this endpoint:
//
// ```js
// // pseudo-code example
// router.get('/recovery', async function (req, res) {
// const flow = await client.getRecoveryFlow(req.header('Cookie'), req.query['flow'])
//
// res.render('recovery', flow)
// })
// ```
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) getRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
rid := x.ParseUUID(r.URL.Query().Get("id"))
f, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), rid)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
// Browser flows must include the CSRF token
//
// Resolves: https://github.com/ory/kratos/issues/1282
if f.Type.IsBrowser() && !f.DangerousSkipCSRFCheck && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), f.CSRFToken) {
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
return
}
if f.ExpiresAt.Before(h.d.Clock().Now().UTC()) {
if f.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(r.Context(), h.d.Config(), RouteInitBrowserFlow, f.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
WithReason("The recovery flow has expired. Redirect the user to the recovery flow init endpoint to initialize a new recovery flow.").
WithDetail("redirect_to", redirectURL.String()).
WithDetail("return_to", f.ReturnTo)))
return
}
h.d.Writer().WriteError(w, r, flow.NewFlowExpiredError(h.d.Clock(), f.ExpiresAt).
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String()))
return
}
h.d.Writer().Write(w, r, f)
}
// Update Recovery Flow Parameters
//
// swagger:parameters updateRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRecoveryFlow struct {
// The Recovery Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/recovery?flow=abcde`).
//
// required: true
// in: query
Flow string `json:"flow"`
// Recovery Token
//
// The recovery token which completes the recovery request. If the token
// is invalid (e.g. expired) an error will be shown to the end-user.
//
// This parameter is usually set in a link and not used by any direct API call.
//
// in: query
Token string `json:"token" form:"token"`
// in: body
// required: true
Body updateRecoveryFlowBody
// 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 Recovery Flow Request Body
//
// swagger:model updateRecoveryFlowBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRecoveryFlowBody struct{}
// swagger:route POST /self-service/recovery frontend updateRecoveryFlow
//
// # Update Recovery Flow
//
// Use this endpoint to update a recovery flow. This endpoint
// behaves differently for API and browser flows and has several states:
//
// - `choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent
// and works with API- and Browser-initiated flows.
// - For API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.
// and a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).
// - For Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.
// - `sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It
// works for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.
// - `passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow ("sending a recovery link")
// does not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL
// (if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with
// a new Recovery Flow ID which contains an error message that the recovery link was invalid.
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 303: emptyResponse
// 400: recoveryFlow
// 410: errorGeneric
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-low
func (h *Handler) updateRecoveryFlow(w http.ResponseWriter, r *http.Request) {
rid, err := flow.GetFlowID(r)
if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
return
}
f, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), rid)
if errors.Is(err, sqlcon.ErrNoRows()) {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, errors.WithStack(herodot.ErrNotFound().WithReasonf("The recovery request could not be found. Please restart the flow.")))
return
} else if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
return
}
if err := f.Valid(h.d.Clock()); err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
return
}
var g node.UiNodeGroup
var found bool
for _, ss := range h.d.AllRecoveryStrategies() {
err := ss.Recover(w, r, f)
if errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, ss.NodeGroup(), err)
return
}
found = true
g = ss.NodeGroup()
break
}
if !found {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, errors.WithStack(schema.NewNoRecoveryStrategyResponsible()))
return
}
// WARNING - just because no error was returned does not mean that the challenge was accepted. Instead, the
// success state is available as:
//
// if f.State == flow.StatePassedChallenge
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(h.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
return
}
updatedFlow, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), f.ID)
if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, g, err)
return
}
updatedFlow.TransientPayload = f.TransientPayload
h.d.Writer().Write(w, r, updatedFlow)
}

View File

@@ -0,0 +1,301 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
)
func init() {
corpx.RegisterFakes()
}
func TestHandlerRedirectOnAuthenticated(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeySelfServiceRecoveryEnabled, true),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
router := httprouterx.NewRouterPublic()
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
redirTS := testhelpers.NewRedirTS(t, "already authenticated", conf)
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+recovery.RouteInitBrowserFlow, nil))
assert.Contains(t, res.Request.URL.String(), redirTS.URL, "%+v", res)
assert.EqualValues(t, "already authenticated", string(body))
})
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+recovery.RouteInitAPIFlow, nil))
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitAPIFlow)
assert.EqualValues(t, text.ErrIDAlreadyLoggedIn, gjson.GetBytes(body, "error.id").Str)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw))
})
}
func TestInitFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(recovery.RecoveryStrategyLink, true)),
configx.WithValues(testhelpers.MethodEnableConfig(recovery.RecoveryStrategyCode, true)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
configx.WithValue(config.ViperKeySelfServiceRecoveryEnabled, true),
)
router := httprouterx.NewRouterPublic()
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
recoveryTS := testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
assertion := func(body []byte, isForced, isApi bool) {
if isApi {
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
} else {
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
}
}
initAuthenticatedFlow := func(t *testing.T, isAPI, isSPA bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
if isAPI {
route = recovery.RouteInitAPIFlow
}
req := testhelpers.NewTestHTTPRequest(t, "GET", publicTS.URL+route, nil)
if isSPA {
req.Header.Set("Accept", "application/json")
}
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, req)
if isAPI {
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
}
return res, body
}
initFlow := func(t *testing.T, isAPI bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
if isAPI {
route = recovery.RouteInitAPIFlow
}
c := publicTS.Client()
res, err := c.Get(publicTS.URL + route)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
initSPAFlow := func(t *testing.T, hc *http.Client, isSPA bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
c := publicTS.Client()
req := testhelpers.NewTestHTTPRequest(t, "GET", publicTS.URL+route, nil)
if isSPA {
req.Header.Set("Accept", "application/json")
}
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
t.Run("flow=api", func(t *testing.T) {
t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, true)
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitAPIFlow)
assertion(body, false, true)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, true, false)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
t.Run("flow=spa", func(t *testing.T) {
t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) {
res, body := initSPAFlow(t, testhelpers.NewTestClient(t), true)
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitBrowserFlow)
assertion(body, false, false)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, false, true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
t.Run("flow=browser", func(t *testing.T) {
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, false)
assertion(body, false, false)
assert.Contains(t, res.Request.URL.String(), recoveryTS.URL)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, false, false)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceBrowserDefaultReturnTo(t.Context()).Host)
})
t.Run("case=relative redirect when self-service recovery ui is a relative URL", func(t *testing.T) {
reg.Config().MustSet(ctx, config.ViperKeySelfServiceRecoveryUI, "/recovery-ts")
assert.Regexp(
t,
"^/recovery-ts.*$",
testhelpers.GetSelfServiceRedirectLocation(t, publicTS.URL+recovery.RouteInitBrowserFlow),
)
})
t.Run("case=redirects with 303", func(t *testing.T) {
c := &http.Client{}
// don't get the reference, instead copy the values, so we don't alter the client directly.
*c = *publicTS.Client()
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", publicTS.URL+recovery.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)
})
})
}
func TestGetFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyLink),
map[string]interface{}{"enabled": true})
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyCode),
map[string]interface{}{"enabled": true})
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
returnToTS := testhelpers.NewRedirTS(t, "", conf)
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRedirTS(t, "", conf)
setupRecoveryTS := func(t *testing.T, c *http.Client) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+recovery.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
require.NoError(t, err)
}))
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUI, ts.URL)
return ts
}
t.Run("case=csrf cookie missing", func(t *testing.T) {
client := testhelpers.NewTestClient(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=valid", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
assert.NotEmpty(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body)
assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), gjson.GetBytes(body, "id").String(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), public.URL, "%s", body)
})
t.Run("case=expired", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
// Expire the flow
f, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RecoveryFlowPersister().UpdateRecoveryFlow(context.Background(), f))
res, body := testhelpers.EasyGet(t, client, public.URL+recovery.RouteGetFlow+"?id="+f.ID.String())
assert.EqualValues(t, http.StatusGone, res.StatusCode)
assert.Equal(t, public.URL+recovery.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
})
t.Run("case=expired with return_to", func(t *testing.T) {
returnTo := returnToTS.URL
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow+"?return_to="+returnTo)
// Expire the flow
f, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RecoveryFlowPersister().UpdateRecoveryFlow(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", public.URL, recovery.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 := public.URL + recovery.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := client.PostForm(u, url.Values{"email": {"email@ory.sh"}, "csrf_token": {f.CSRFToken}, "method": {"link"}})
require.NoError(t, err)
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
require.NoError(t, err)
assert.Equal(t, public.URL+recovery.RouteInitBrowserFlow+"?return_to="+returnTo, f.RequestURL)
})
t.Run("case=not found", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
res, _ := testhelpers.EasyGet(t, client, public.URL+recovery.RouteGetFlow+"?id="+x.NewUUID().String())
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
})
}

View File

@@ -0,0 +1,134 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"fmt"
"net/http"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"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/ui/node"
)
type (
PreHookExecutor interface {
ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
}
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
PostHookExecutor interface {
ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
}
PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
HooksProvider interface {
PreRecoveryHooks(ctx context.Context) ([]PreHookExecutor, error)
PostRecoveryHooks(ctx context.Context) ([]PostHookExecutor, error)
}
)
func PostHookRecoveryExecutorNames(e []PostHookExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func (f PreHookExecutorFunc) ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
return f(w, r, a)
}
func (f PostHookExecutorFunc) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
return f(w, r, a, s)
}
type (
executorDependencies interface {
config.Provider
identity.ManagementProvider
identity.ValidationProvider
session.PersistenceProvider
HooksProvider
nosurfx.CSRFTokenGeneratorProvider
logrusx.Provider
httpx.WriterProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
RecoveryExecutor() *HookExecutor
}
)
func NewHookExecutor(d executorDependencies) *HookExecutor {
return &HookExecutor{
d: d,
}
}
func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
logger := e.d.Logger().
WithRequest(r)
if s.Identity != nil {
logger = logger.WithField("identity_id", s.Identity.ID)
}
logger.Debug("Running ExecutePostRecoveryHooks.")
hooks, err := e.d.PostRecoveryHooks(r.Context())
if err != nil {
return err
}
for k, executor := range hooks {
if err := executor.ExecutePostRecoveryHook(w, r, a, s); err != nil {
var traits identity.Traits
if s.Identity != nil {
traits = s.Identity.Traits
}
return flow.HandleHookError(w, r, a, traits, node.LinkGroup, err, e.d, e.d)
}
logger.
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookRecoveryExecutorNames(hooks)).
Debug("ExecutePostRecoveryHook completed successfully.")
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoverySucceeded(r.Context(), a.ID, s.Identity.ID, string(a.Type), a.Active.String()))
logger.Debug("Post recovery execution hooks completed successfully.")
return nil
}
func (e *HookExecutor) PreRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
hooks, err := e.d.PreRecoveryHooks(r.Context())
if err != nil {
return err
}
for _, executor := range hooks {
if err := executor.ExecuteRecoveryPreHook(w, r, a); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,119 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/gobuffalo/httptest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/configx"
"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/recovery"
"github.com/ory/kratos/selfservice/strategy/code"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
)
func TestRecoveryExecutor(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
s := recovery.Strategies{code.NewStrategy(reg)}
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
router := http.NewServeMux()
router.HandleFunc("GET /recovery/pre", func(w http.ResponseWriter, r *http.Request) {
a, err := recovery.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, s, ft)
require.NoError(t, err)
if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PreRecoveryHook(w, r, a)) {
_, _ = w.Write([]byte("ok"))
}
})
router.HandleFunc("GET /recovery/post", func(w http.ResponseWriter, r *http.Request) {
a, err := recovery.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, s, ft)
require.NoError(t, err)
s, err := testhelpers.NewActiveSession(r,
reg,
i,
time.Now().UTC(),
identity.CredentialsTypeRecoveryLink,
identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
a.RequestURL = x.RequestURL(r).String()
if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PostRecoveryHook(w, r, a, s)) {
_, _ = w.Write([]byte("ok"))
}
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
return ts
}
t.Run("method=PostRecoveryHook", func(t *testing.T) {
t.Run("case=pass without hooks", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
})
t.Run("case=fail if hooks fail", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "abort"}`)}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "", body)
})
})
for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(kind)+"/method=PreRecoveryHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceRecoveryBeforeHooks,
testhelpers.SelfServiceMakeRecoveryPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, nil, kind)
},
conf,
))
}
}

View File

@@ -0,0 +1,23 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type (
FlowPersister interface {
CreateRecoveryFlow(context.Context, *Flow) error
GetRecoveryFlow(ctx context.Context, id uuid.UUID) (*Flow, error)
UpdateRecoveryFlow(context.Context, *Flow) error
DeleteExpiredRecoveryFlows(context.Context, time.Time, int) error
}
FlowPersistenceProvider interface {
RecoveryFlowPersister() FlowPersister
}
)

View File

@@ -0,0 +1,17 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import "github.com/ory/kratos/selfservice/flow"
// Recovery Flow State
//
// The state represents the state of the recovery flow.
//
// - choose_method: ask the user to choose a method (e.g. recover account via email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the recovery challenge was passed.
//
// swagger:model recoveryFlowState
type State = flow.State

View File

@@ -0,0 +1,57 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/ui/node"
)
//swagger:enum RecoveryMethod
type RecoveryMethod string
const (
RecoveryStrategyLink RecoveryMethod = "link"
RecoveryStrategyCode RecoveryMethod = "code"
)
type (
Strategy interface {
RecoveryStrategyID() string
IsPrimary() bool
NodeGroup() node.UiNodeGroup
PopulateRecoveryMethod(*http.Request, *Flow) error
Recover(w http.ResponseWriter, r *http.Request, f *Flow) (err error)
}
Strategies []Strategy
StrategyProvider interface {
AllRecoveryStrategies() Strategies
RecoveryStrategies(ctx context.Context) Strategies
GetActiveRecoveryStrategies(ctx context.Context) (active Strategies, primary Strategy, err error)
}
)
func (s Strategies) ActiveStrategies(id string) (active Strategies, primary Strategy, err error) {
ids := make([]string, len(s))
for k, ss := range s {
ids[k] = ss.RecoveryStrategyID()
if ss.RecoveryStrategyID() == id || !ss.IsPrimary() {
active = append(active, ss)
if ss.IsPrimary() {
primary = ss
}
}
}
if primary == nil {
return nil, nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("unable to find strategy for %s have %v", id, ids))
}
return active, primary, nil
}

View File

@@ -0,0 +1,46 @@
{
"$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"
},
"recovery": {
"via": "email"
}
}
},
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"booly": {
"type": "boolean"
},
"should_big_number": {
"type": "number",
"minimum": 1200
},
"should_long_string": {
"type": "string",
"minLength": 25
}
}
}
}
}

View File

@@ -0,0 +1,169 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"testing"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/persistence"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/sqlcon"
)
func TestFlowPersister(ctx context.Context, p interface {
persistence.Persister
},
) func(t *testing.T) {
clearids := func(r *recovery.Flow) {
r.ID = uuid.UUID{}
}
return func(t *testing.T) {
nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=should error when the recovery request does not exist", func(t *testing.T) {
_, err := p.GetRecoveryFlow(ctx, x.NewUUID())
require.Error(t, err)
})
newFlow := func(t *testing.T) *recovery.Flow {
var r recovery.Flow
require.NoError(t, faker.FakeData(&r))
clearids(&r)
r.State = flow.StateShowForm
return &r
}
t.Run("case=should create a new recovery request", func(t *testing.T) {
r := newFlow(t)
err := p.CreateRecoveryFlow(ctx, r)
require.NoError(t, err, "%#v", err)
require.Equal(t, nid, r.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create with set ids", func(t *testing.T) {
var r recovery.Flow
require.NoError(t, faker.FakeData(&r))
r.State = flow.StateShowForm
require.NoError(t, p.CreateRecoveryFlow(ctx, &r))
require.Equal(t, nid, r.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create and fetch a recovery request", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateRecoveryFlow(ctx, expected)
require.NoError(t, err)
require.Equal(t, nid, expected.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, expected.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assert.EqualValues(t, expected.ID, actual.ID)
x.AssertEqualTime(t, expected.IssuedAt, actual.IssuedAt)
x.AssertEqualTime(t, expected.ExpiresAt, actual.ExpiresAt)
assert.EqualValues(t, expected.RequestURL, actual.RequestURL)
assertx.EqualAsJSON(t, expected.UI, actual.UI, "expected:\t%s\nactual:\t%s", expected.UI, actual.UI)
})
t.Run("case=should create and update a recovery 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.CreateRecoveryFlow(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"
t.Run("fail to find on other network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
require.ErrorIs(t, other.UpdateRecoveryFlow(ctx, expected), sqlcon.ErrNoRows())
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assert.NotEqual(t, "/new-request-url", actual.RequestURL)
})
require.NoError(t, p.UpdateRecoveryFlow(ctx, expected))
actual, err := p.GetRecoveryFlow(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=should not cause data loss when updating a request without changes", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateRecoveryFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
require.NoError(t, p.UpdateRecoveryFlow(ctx, actual))
actual, err = p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assertx.EqualAsJSON(t, expected.UI, actual.UI)
})
t.Run("case=handle network reference issues", func(t *testing.T) {
})
}
}

View File

@@ -0,0 +1,81 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "YTc3djZwaWpsZTFha3UyNHRlMDMyaTRxaHMxMWVmcmk=",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "traits.username",
"type": "text",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"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": "default",
"attributes": {
"name": "traits.foobar",
"type": "text",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,29 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"net/http"
"net/url"
"github.com/pkg/errors"
"github.com/tidwall/sjson"
"github.com/ory/x/decoderx"
)
func DecodeBody(p interface{}, r *http.Request, schema []byte, ds *url.URL) error {
raw, err := sjson.SetBytes(schema,
"properties.traits.$ref", ds.String()+"#/properties/traits")
if err != nil {
return errors.WithStack(err)
}
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(raw)
if err != nil {
return errors.WithStack(err)
}
return decoderx.Decode(r, p, compiler, decoderx.HTTPDecoderSetValidatePayloads(true), decoderx.HTTPDecoderJSONFollowsFormFormat())
}

View File

@@ -0,0 +1,218 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"net/http"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/x/redir"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/x"
)
var ErrHookAbortFlow = errors.New("aborted registration hook execution")
func ErrAlreadyLoggedIn() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDAlreadyLoggedIn).WithError("you are already logged in").WithReason("A valid session was detected and thus registration is not possible.")
}
func ErrRegistrationDisabled() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDSelfServiceFlowDisabled).WithError("registration flow disabled").WithReason("Registration is not allowed because it was disabled.")
}
type (
errorHandlerDependencies interface {
clock.Provider
errorx.ManagementProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
config.Provider
hydra.Provider
sessiontokenexchange.PersistenceProvider
FlowPersistenceProvider
HandlerProvider
}
ErrorHandlerProvider interface{ RegistrationFlowErrorHandler() *ErrorHandler }
ErrorHandler struct {
d errorHandlerDependencies
}
)
func NewErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}
func (s *ErrorHandler) PrepareReplacementForExpiredFlow(w http.ResponseWriter, r *http.Request, f *Flow, err error) (*flow.ExpiredError, error) {
errExpired := new(flow.ExpiredError)
if !errors.As(err, &errExpired) {
return nil, nil
}
// create new flow because the old one is not valid
newFlow, err := s.d.RegistrationHandler().FromOldFlow(w, r, *f)
if err != nil {
return nil, err
}
newFlow.UI.Messages.Add(text.NewErrorValidationRegistrationFlowExpired(s.d.Clock(), errExpired.ExpiredAt))
if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(r.Context(), newFlow); err != nil {
return nil, err
}
return errExpired.WithFlow(newFlow), nil
}
func (s *ErrorHandler) WriteFlowError(
w http.ResponseWriter,
r *http.Request,
f *Flow,
ct identity.CredentialsType,
group node.UiNodeGroup,
err error,
) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.registration.ErrorHandler.WriteFlowError",
trace.WithAttributes(
attribute.String("error", err.Error()),
))
r = r.WithContext(ctx)
defer otelx.End(span, &err)
if dup := new(identity.ErrDuplicateCredentials); errors.As(err, &dup) {
err = schema.NewDuplicateCredentialsError(dup)
}
logger := s.d.Logger().
WithError(err).
WithRequest(r).
WithField("registration_flow", f.ToLoggerField())
logger.Info("Encountered self-service flow error.")
if f == nil {
trace.SpanFromContext(r.Context()).AddEvent(events.NewRegistrationFailed(r.Context(), uuid.Nil, "", "", err))
s.forward(w, r, nil, err)
return
}
span.SetAttributes(attribute.String("flow_id", f.ID.String()))
trace.SpanFromContext(r.Context()).AddEvent(events.NewRegistrationFailed(r.Context(), f.ID, string(f.Type), ct.String(), err))
if expired, inner := s.PrepareReplacementForExpiredFlow(w, r, f, err); inner != nil {
s.forward(w, r, f, err)
return
} else if expired != nil {
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().SelfServiceFlowRegistrationUI(r.Context())).String(), http.StatusSeeOther)
}
return
}
f.UI.ResetMessages()
if err := f.UI.ParseError(group, err); err != nil {
s.forward(w, r, f, err)
return
}
ds, err := f.IdentitySchema.URL(r.Context(), s.d.Config())
if err != nil {
s.forward(w, r, f, err)
return
}
if err := SortNodes(r.Context(), f.UI.Nodes, ds.String()); err != nil {
s.forward(w, r, f, err)
return
}
if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(r.Context(), f); err != nil {
s.forward(w, r, f, err)
return
}
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRegistrationUI(r.Context())).String(), http.StatusFound)
return
}
if codes, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); group == node.OpenIDConnectGroup && f.Type == flow.TypeAPI && hasCode {
returnTo, err := redir.SecureRedirectTo(r,
s.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()),
f.SecureRedirectToOpts(r.Context(), s.d)...,
)
if err != nil {
s.forward(w, r, f, errors.WithStack(err))
return
}
q := returnTo.Query()
q.Set("code", codes.ReturnToCode)
q.Set("flow", f.ID.String())
returnTo.RawQuery = q.Encode()
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
return
}
updatedFlow, innerErr := s.d.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), f.ID)
if innerErr != nil {
s.forward(w, r, updatedFlow, innerErr)
return
}
// If the flow has an OAuth2LoginChallenge, we try to fetch the login request from Hydra because we return the flow object as JSON.
if updatedFlow.OAuth2LoginChallenge != "" {
hlr, err := s.d.Hydra().GetLoginRequest(ctx, string(updatedFlow.OAuth2LoginChallenge))
if err != nil {
// We don't redirect back to the third party on errors because Hydra doesn't
// give us the 3rd party return_uri when it redirects to the login UI.
s.forward(w, r, updatedFlow, err)
return
}
updatedFlow.HydraLoginRequest = hlr
}
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), updatedFlow)
}
func (s *ErrorHandler) forward(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(r.Context(), w, r, err)
return
}
if rr.Type == flow.TypeAPI {
s.d.Writer().WriteErrorCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), err)
} else {
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
}
}

View File

@@ -0,0 +1,309 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/clock"
"github.com/ory/x/configx"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/registration"
"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/sqlxx"
"github.com/ory/x/urlx"
)
type opts struct {
oauth2LoginChallenge string
}
func withLoginChallenge(challenge string) func(*opts) {
return func(o *opts) {
o.oauth2LoginChallenge = challenge
}
}
func TestHandleError(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeySelfServiceRegistrationEnabled, true),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
)
reg.SetHydra(hydra.NewFake())
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
testhelpers.NewErrorTestServer(t, reg)
h := reg.RegistrationFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var registrationFlow *registration.Flow
var flowError error
var group node.UiNodeGroup
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(w, r, registrationFlow, "", group, flowError)
})
reset := func() {
registrationFlow = nil
flowError = nil
group = ""
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type, options ...func(*opts)) *registration.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
f, err := registration.NewFlow(reg, req, ft)
require.NoError(t, err)
opts := &opts{}
for _, o := range options {
o(opts)
}
if opts.oauth2LoginChallenge != "" {
f.OAuth2LoginChallenge = sqlxx.NullString(opts.oauth2LoginChallenge)
}
for _, s := range reg.RegistrationStrategies(context.Background()) {
var populateErr error
switch strategy := s.(type) {
case registration.FormHydrator:
switch {
case conf.SelfServiceFlowRegistrationTwoSteps(ctx):
populateErr = strategy.PopulateRegistrationMethodProfile(req, f)
default:
populateErr = strategy.PopulateRegistrationMethod(req, f)
}
case registration.UnifiedFormHydrator:
populateErr = strategy.PopulateRegistrationMethod(req, f)
default:
populateErr = errors.WithStack(x.PseudoPanic.WithReasonf("A registration strategy was expected to implement one of the interfaces UnifiedFormHydrator or FormHydrator but did not."))
}
require.NoError(t, populateErr)
}
require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(context.Background(), f))
return f
}
expectErrorUI := func(t *testing.T) (interface{}, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
require.NoError(t, err)
return sse.Error, nil
}
anHourAgo := time.Now().Add(-time.Hour)
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
group = node.PasswordGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=error with nil flow detects application/json", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
group = node.PasswordGroup
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Header.Get("Content-Type"), "application/json")
assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "system error")
})
for _, tc := range []struct {
n string
t flow.Type
}{
{"api", flow.TypeAPI},
{"spa", flow.TypeBrowser},
} {
t.Run("flow="+tc.n, func(t *testing.T) {
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
group = node.PasswordGroup
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, http.StatusGone, res.StatusCode, "%+v\n\t%s", res.Request, body)
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.GetBytes(body, "use_flow_id").String())
assert.Equal(t, text.ErrIDSelfServiceFlowExpired, gjson.GetBytes(body, "error.id").String(), "%s", body)
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
group = node.PasswordGroup
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body)
assert.Equal(t, registrationFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
group = node.PasswordGroup
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
})
t.Run("case=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeInvalidLoginChallenge))
group = node.PasswordGroup
flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error")
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body)
require.Equal(t, "Unable to get OAuth 2.0 Login Challenge.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=fetches hydra login request", func(t *testing.T) {
t.Cleanup(reset)
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, nil)
})
registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeValidLoginChallenge))
group = node.PasswordGroup
flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error")
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body)
require.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body)
})
})
}
t.Run("flow=browser", func(t *testing.T) {
expectRegistrationUI := func(t *testing.T) (*registration.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.SelfServiceFlowRegistrationUI(ctx).String()+"?flow=")
rf, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return rf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = &registration.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
group = node.PasswordGroup
lf, _ := expectRegistrationUI(t)
require.Len(t, lf.UI.Messages, 1)
assert.Equal(t, int(text.ErrorValidationRegistrationFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
group = node.PasswordGroup
lf, _ := expectRegistrationUI(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)
registrationFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
group = node.PasswordGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
})
}

View File

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

View File

@@ -0,0 +1,81 @@
[
{
"attributes": {
"disabled": false,
"name": "traits.foobar",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.username",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden",
"value": "YTc3djZwaWpsZTFha3UyNHRlMDMyaTRxaHMxMWVmcmk="
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,308 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"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"
)
// swagger:model registrationFlow
type Flow struct {
// ID represents the flow's unique ID. When performing the registration flow, this
// represents the id in the registration ui's query parameter: http://<selfservice.flows.registration.ui_url>/?flow=<id>
//
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id"`
// Ory OAuth 2.0 Login Challenge.
//
// This value is set using the `login_challenge` query parameter of the registration and login endpoints.
// If set will cooperate with Ory OAuth2 and OpenID to act as an OAuth2 server / OpenID Provider.
OAuth2LoginChallenge sqlxx.NullString `json:"oauth2_login_challenge,omitempty" faker:"-" db:"oauth2_login_challenge_data"`
// HydraLoginRequest is an optional field whose presence indicates that Kratos
// is being used as an identity provider in a Hydra OAuth2 flow. Kratos
// populates this field by retrieving its value from Hydra and it is used by
// the login and consent UIs.
HydraLoginRequest *hydraclientgo.OAuth2LoginRequest `json:"oauth2_login_request,omitempty" faker:"-" db:"-"`
// 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 log in,
// 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"`
// InternalContext stores internal context used by internals - for example MFA keys.
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
// 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" faker:"url" db:"request_url"`
// ReturnTo contains the requested return_to URL.
ReturnTo string `json:"return_to,omitempty" db:"-"`
// ReturnToVerification contains the redirect URL for the verification flow.
ReturnToVerification string `json:"-" db:"-"`
// Active, if set, contains the registration method that is being used. It is initially
// not set.
Active identity.CredentialsType `json:"active,omitempty" faker:"identity_credentials_type" db:"active_method"`
// UI contains data which must be shown in the user interface.
//
// required: true
UI *container.Container `json:"ui" db:"ui"`
// 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"`
// CSRFToken contains the anti-csrf token associated with this flow. Only set for browser flows.
CSRFToken string `json:"-" db:"csrf_token"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
OrganizationID uuid.NullUUID `json:"organization_id,omitempty" faker:"-" db:"organization_id"`
// TransientPayload is used to pass data from the registration to a webhook
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
// 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.
ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-" `
// SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed.
// This is only set if the client has requested a session token exchange code, and if the flow is of type "api",
// and only on creating the flow.
SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"`
// State represents the state of this request:
//
// - choose_method: ask the user to choose a method (e.g. registration with email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the registration challenge was passed.
// required: true
State State `json:"state" faker:"-" db:"state"`
// only used internally
IDToken string `json:"-" faker:"-" db:"-"`
// Only used internally
RawIDTokenNonce string `json:"-" db:"-"`
// IdentitySchema optionally holds the ID of the identity schema that is used
// for this flow. This value can be set by the user when creating the flow and
// should be retained when the flow is saved or converted to another flow.
IdentitySchema flow.IdentitySchema `json:"identity_schema,omitempty" faker:"-" db:"identity_schema_id"`
}
var _ flow.Flow = new(Flow)
// flowDependencies are the dependencies NewFlow needs to construct a
// registration flow: the configuration (for the lifespan and return-to
// validation), the clock, and the CSRF token generator.
type flowDependencies interface {
config.Provider
clock.Provider
nosurfx.CSRFTokenGeneratorProvider
}
func NewFlow(reg flowDependencies, r *http.Request, ft flow.Type) (*Flow, error) {
conf := reg.Config()
now := reg.Clock().Now().UTC()
exp := conf.SelfServiceFlowRegistrationRequestLifespan(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
}
hlc, err := hydra.GetLoginChallengeID(conf, r)
if err != nil {
return nil, err
}
identitySchema := ""
if requestedSchema := r.URL.Query().Get("identity_schema"); requestedSchema != "" {
identitySchema, err = conf.SelfServiceFlowIdentitySchema(r.Context(), requestedSchema)
if err != nil {
return nil, err
}
}
f := &Flow{
ID: id,
OAuth2LoginChallenge: hlc,
ExpiresAt: now.Add(exp),
IssuedAt: now,
RequestURL: requestURL,
UI: &container.Container{
Method: "POST",
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
},
CSRFToken: reg.GenerateCSRFToken(r),
Type: ft,
InternalContext: []byte("{}"),
State: flow.StateChooseMethod,
IdentitySchema: flow.IdentitySchema(identitySchema),
}
if err := flow.SetRequestBaseURL(f, x.BaseURLStringFromContext(r.Context())); err != nil {
return nil, err
}
return f, nil
}
func (Flow) TableName() string { return "selfservice_registration_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) GetType() flow.Type { return f.Type }
func (f *Flow) GetRequestURL() string { return f.RequestURL }
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { f.InternalContext = bytes }
func (f *Flow) GetUI() *container.Container { return f.UI }
func (f *Flow) GetState() State { return f.State }
func (Flow) GetFlowName() flow.FlowName { return flow.RegistrationFlow }
func (f *Flow) SetState(state State) { f.State = state }
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
func (f *Flow) SetReturnToVerification(to string) { f.ReturnToVerification = to }
func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge }
func (f *Flow) GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest { return f.HydraLoginRequest }
func (f *Flow) Valid(c clock.Clock) error {
if f.ExpiresAt.Before(c.Now()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
return nil
}
func (f *Flow) EnsureInternalContext() {
if !gjson.ParseBytes(f.InternalContext).IsObject() {
f.InternalContext = []byte("{}")
}
}
const internalContextAccountLinkingPath = "converted_for_account_linking"
// MarkAsAccountLinking records in the flow's internal context that this
// registration flow was created internally by converting a login flow to
// perform account linking. Such flows may exist while registration is
// disabled, but must never create a new identity in that case.
func (f *Flow) MarkAsAccountLinking() {
f.EnsureInternalContext()
// The path is a constant and the internal context is guaranteed to be a
// JSON object at this point, so this cannot fail.
if bytes, err := sjson.SetBytes(f.InternalContext, internalContextAccountLinkingPath, true); err == nil {
f.InternalContext = bytes
}
}
// IsAccountLinking returns true if this registration flow was created
// internally by converting a login flow to perform account linking.
func (f *Flow) IsAccountLinking() bool {
return gjson.GetBytes(f.InternalContext, internalContextAccountLinkingPath).Bool()
}
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) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems }
func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []redir.SecureRedirectOption) {
return []redir.SecureRedirectOption{
redir.SecureRedirectReturnTo(f.ReturnTo),
redir.SecureRedirectUseSourceURL(f.RequestURL),
redir.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowRegistrationReturnTo(ctx, f.Active.String())),
}
}
func (f *Flow) ToLoggerField() map[string]any {
if f == nil {
return map[string]any{}
}
return map[string]any{
"id": f.ID.String(),
"return_to": f.ReturnTo,
"request_url": f.RequestURL,
"active": f.Active,
"Type": f.Type,
"nid": f.NID,
"state": f.State,
}
}

View File

@@ -0,0 +1,171 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration_test
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/x"
"github.com/ory/x/clock"
"github.com/ory/x/jsonx"
"github.com/ory/x/urlx"
)
func TestFakeFlow(t *testing.T) {
var r registration.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.NewFastRegistryWithMocks(t)
t.Run("case=0", func(t *testing.T) {
r, err := registration.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/"),
Host: "ory.sh", TLS: &tls.ConnectionState{},
}, 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 := registration.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, flow.TypeBrowser)
require.Error(t, err)
_, err = registration.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
})
t.Run("case=1", func(t *testing.T) {
r, err := registration.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("/?refresh=true"),
Host: "ory.sh",
}, 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 := registration.NewFlow(reg, &http.Request{
URL: urlx.ParseOrPanic("https://ory.sh/"),
Host: "ory.sh",
}, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "https://ory.sh/", r.RequestURL)
})
t.Run("should parse login_challenge when Hydra is configured", func(t *testing.T) {
_, err := registration.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=badee1"), Host: "ory.sh"}, flow.TypeBrowser)
require.Error(t, err)
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra")
r, err := registration.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=8aadcb8fc1334186a84c4da9813356d9"), Host: "ory.sh"}, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "8aadcb8fc1334186a84c4da9813356d9", string(r.OAuth2LoginChallenge))
})
}
func TestFlow(t *testing.T) {
r := &registration.Flow{ID: x.NewUUID()}
assert.Equal(t, r.ID, r.GetID())
t.Run("case=expired", func(t *testing.T) {
for _, tc := range []struct {
r *registration.Flow
valid bool
}{
{
r: &registration.Flow{ExpiresAt: time.Now().Add(time.Hour), IssuedAt: time.Now().Add(-time.Minute)},
valid: true,
},
{r: &registration.Flow{ExpiresAt: time.Now().Add(-time.Hour), IssuedAt: time.Now().Add(-time.Minute)}},
} {
if tc.valid {
require.NoError(t, tc.r.Valid(clock.New()))
} else {
require.Error(t, tc.r.Valid(clock.New()))
}
}
})
}
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 := &registration.Flow{Type: ft}
assert.Equal(t, ft, r.GetType())
})
}
}
func TestGetRequestURL(t *testing.T) {
expectedURL := "http://foo/bar/baz"
f := &registration.Flow{RequestURL: expectedURL}
assert.Equal(t, expectedURL, f.GetRequestURL())
}
func TestFlowEncodeJSON(t *testing.T) {
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &registration.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &registration.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, registration.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
}
func TestFlowDontOverrideReturnTo(t *testing.T) {
f := &registration.Flow{ReturnTo: "/foo"}
f.SetReturnTo()
assert.Equal(t, "/foo", f.ReturnTo)
f = &registration.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
f.SetReturnTo()
assert.Equal(t, "/bar", f.ReturnTo)
}
func TestFlowAccountLinkingMarker(t *testing.T) {
t.Parallel()
f := &registration.Flow{}
assert.False(t, f.IsAccountLinking())
registration.WithFlowAccountLinking()(f)
assert.True(t, f.IsAccountLinking())
// The marker must survive an internal-context round trip, because the
// flow is persisted between requests.
var copied registration.Flow
copied.SetInternalContext(f.GetInternalContext())
assert.True(t, copied.IsAccountLinking())
}

View File

@@ -0,0 +1,760 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"net/http"
"net/url"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
hydraclientgo "github.com/ory/hydra-client-go/v2"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/sessiontokenexchange"
"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/events"
"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/semconv"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
const (
RouteInitBrowserFlow = "/self-service/registration/browser"
RouteInitAPIFlow = "/self-service/registration/api"
RouteGetFlow = "/self-service/registration/flows"
RouteSubmitFlow = "/self-service/registration"
)
type (
handlerDependencies interface {
clock.Provider
config.Provider
errorx.ManagementProvider
hydra.Provider
session.HandlerProvider
session.ManagementProvider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
StrategyProvider
HookExecutorProvider
FlowPersistenceProvider
ErrorHandlerProvider
sessiontokenexchange.PersistenceProvider
logrusx.Provider
}
HandlerProvider interface {
RegistrationHandler() *Handler
}
Handler struct {
d handlerDependencies
}
)
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
public.GET(RouteInitBrowserFlow, h.createBrowserRegistrationFlow)
public.GET(RouteInitAPIFlow, h.d.SessionHandler().IsNotAuthenticated(h.createNativeRegistrationFlow,
session.RespondWithJSONErrorOnAuthenticated(h.d.Writer(), errors.WithStack(ErrAlreadyLoggedIn()))))
public.GET(RouteGetFlow, h.getRegistrationFlow)
public.POST(RouteSubmitFlow, h.d.SessionHandler().IsNotAuthenticated(h.updateRegistrationFlow, h.onAuthenticated))
public.GET(RouteSubmitFlow, h.d.SessionHandler().IsNotAuthenticated(h.updateRegistrationFlow, h.onAuthenticated))
}
func (h *Handler) onAuthenticated(w http.ResponseWriter, r *http.Request) {
handler := session.RedirectOnAuthenticated(h.d)
if x.IsJSONRequest(r) {
handler = session.RespondWithJSONErrorOnAuthenticated(h.d.Writer(), ErrAlreadyLoggedIn())
}
handler(w, r)
}
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))
}
type FlowOption func(f *Flow)
func WithFlowReturnTo(returnTo string) FlowOption {
return func(f *Flow) {
f.ReturnTo = returnTo
}
}
func WithFlowOAuth2LoginChallenge(loginChallenge string) FlowOption {
return func(f *Flow) {
f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge)
}
}
func WithFlowIdentitySchema(schema string) FlowOption {
return func(f *Flow) {
f.IdentitySchema = flow.IdentitySchema(schema)
}
}
func WithFlowAccountLinking() FlowOption {
return func(f *Flow) {
f.MarkAsAccountLinking()
}
}
func (h *Handler) NewRegistrationFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, error) {
f, err := NewFlow(h.d, r, ft)
if err != nil {
return nil, err
}
for _, o := range opts {
o(f)
}
// Flows that were converted from a login flow for account linking may be
// created even when registration is disabled. PostRegistrationHook
// guarantees they can only link to an existing identity, never create one.
if !h.d.Config().SelfServiceFlowRegistrationEnabled(r.Context()) && !f.IsAccountLinking() {
return nil, errors.WithStack(ErrRegistrationDisabled())
}
if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" {
e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID)
if err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError().WithWrap(err))
}
f.SessionTokenExchangeCode = e.InitCode
}
for _, s := range h.d.RegistrationStrategies(r.Context(), PrepareOrganizations(r, f)...) {
var populateErr error
switch strategy := s.(type) {
case FormHydrator:
if h.d.Config().SelfServiceFlowRegistrationTwoSteps(r.Context()) {
populateErr = strategy.PopulateRegistrationMethodProfile(r, f)
} else {
populateErr = strategy.PopulateRegistrationMethod(r, f)
}
case UnifiedFormHydrator:
populateErr = strategy.PopulateRegistrationMethod(r, f)
default:
populateErr = errors.WithStack(x.PseudoPanic.WithReasonf("A registration strategy was expected to implement one of the interfaces UnifiedFormHydrator or FormHydrator but did not."))
}
if populateErr != nil {
return nil, populateErr
}
}
ds, err := f.IdentitySchema.URL(r.Context(), h.d.Config())
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.RegistrationExecutor().PreRegistrationHook(w, r, f); err != nil {
return nil, err
}
if err := h.d.RegistrationFlowPersister().CreateRegistrationFlow(r.Context(), f); err != nil {
return nil, err
}
span := trace.SpanFromContext(r.Context())
span.AddEvent(events.NewRegistrationInitiated(r.Context(), f.ID, string(ft), f.OrganizationID))
return f, nil
}
func (h *Handler) FromOldFlow(w http.ResponseWriter, r *http.Request, of Flow) (*Flow, error) {
nf, err := h.NewRegistrationFlow(w, r, of.Type)
if err != nil {
return nil, err
}
nf.RequestURL = of.RequestURL
nf.IdentitySchema = of.IdentitySchema
return nf, nil
}
// swagger:route GET /self-service/registration/api frontend createNativeRegistrationFlow
//
// # Create Registration Flow for Native Apps
//
// This endpoint initiates a registration flow for API clients such as mobile devices, smart TVs, and so on.
//
// If a valid provided session cookie or session token is provided, a 400 Bad Request error
// will be returned unless the URL query parameter `?refresh=true` is set.
//
// To fetch an existing registration flow call `/self-service/registration/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.
//
// In the case of an error, the `error.id` of the JSON response body can be one of:
//
// - `session_already_available`: The user is already signed in.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
//
// 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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Schemes: http, https
//
// Responses:
// 200: registrationFlow
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createNativeRegistrationFlow(w http.ResponseWriter, r *http.Request) {
a, err := h.NewRegistrationFlow(w, r, flow.TypeAPI)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, a)
}
// Create Native Registration Flow Parameters
//
// swagger:parameters createNativeRegistrationFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createNativeRegistrationFlow struct {
// EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token
// after the login flow has been completed.
//
// in: query
EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"`
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// An optional organization ID that should be used to register this user.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
// An optional identity schema to use for the registration flow.
//
// required: false
// in: query
IdentitySchema string `json:"identity_schema"`
}
// Create Browser Registration Flow Parameters
//
// swagger:parameters createBrowserRegistrationFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserRegistrationFlow struct {
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// Ory OAuth 2.0 Login Challenge.
//
// If set will cooperate with Ory OAuth2 and OpenID to act as an OAuth2 server / OpenID Provider.
//
// The value for this parameter comes from `login_challenge` URL Query parameter sent to your
// application (e.g. `/registration?login_challenge=abcde`).
//
// This feature is compatible with Ory Hydra when not running on the Ory Network.
//
// required: false
// in: query
LoginChallenge string `json:"login_challenge"`
// The URL to return the browser to after the verification flow was completed.
//
// After the registration flow is completed, the user will be sent a verification email.
// Upon completing the verification flow, this URL will be used to override the default
// `selfservice.flows.verification.after.default_redirect_to` value.
//
// required: false
// in: query
AfterVerificationReturnTo string `json:"after_verification_return_to"`
// An optional organization ID that should be used to register this user.
// This parameter is only effective in the Ory Network.
//
// required: false
// in: query
Organization string `json:"organization"`
// An optional identity schema to use for the registration flow.
//
// required: false
// in: query
IdentitySchema string `json:"identity_schema"`
}
// swagger:route GET /self-service/registration/browser frontend createBrowserRegistrationFlow
//
// # Create Registration Flow for Browsers
//
// This endpoint initializes a browser-based user registration flow. This endpoint will set the appropriate
// cookies and anti-CSRF measures required for browser-based flows.
//
// If this endpoint is opened as a link in the browser, it will be redirected to
// `selfservice.flows.registration.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
// exists already, the browser will be redirected to `urls.default_redirect_url`.
//
// 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:
//
// - `session_already_available`: The user is already signed in.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
//
// If this endpoint is called via an AJAX request, the response contains the registration flow without a redirect.
//
// 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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Schemes: http, https
//
// Produces:
// - application/json
//
// Responses:
// 200: registrationFlow
// 303: emptyResponse
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserRegistrationFlow(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a, err := h.NewRegistrationFlow(w, r, flow.TypeBrowser)
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
var (
hydraLoginRequest *hydraclientgo.OAuth2LoginRequest
hydraLoginChallenge sqlxx.NullString
)
if r.URL.Query().Has("login_challenge") {
var err error
hydraLoginChallenge, err = hydra.GetLoginChallengeID(h.d.Config(), r)
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(ctx, hydraLoginChallenge.String())
if err != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
return
}
// on OAuth2 flows, we need to use the RequestURL
// as the ReturnTo URL.
// This is because a user might want to switch between
// different flows, such as login to registration and login to recovery.
// After completing a complex flow, such as recovery, we want the user
// to be redirected back to the original OAuth2 login flow.
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(r.Context()) {
q := r.URL.Query()
// replace the return_to query parameter
q.Set("return_to", hydraLoginRequest.RequestUrl)
r.URL.RawQuery = q.Encode()
}
}
// Only session columns (identity ID, session ID, AMR) are read below, so skip loading the
// devices and the identity.
if sess, err := h.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandNothing, identity.ExpandNothing); err == nil {
if hydraLoginRequest != nil {
if hydraLoginRequest.GetSkip() {
rt, err := h.d.Hydra().AcceptLoginRequest(r.Context(),
hydra.AcceptLoginRequestParams{
LoginChallenge: string(hydraLoginChallenge),
IdentityID: sess.IdentityID.String(),
SessionID: sess.ID.String(),
AuthenticationMethods: sess.AMR,
})
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
returnTo, err := url.Parse(rt)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to parse URL: %s", rt)))
return
}
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
return
}
// hydra indicates that we cannot skip the login request
// so we must perform the login flow.
// we directly go to the login handler from here
// copy over any query parameters, such as `return_to` and `login_challenge`
loginURL := urlx.CopyWithQuery(urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), "/self-service/login/browser"), x.RequestURL(r).Query())
http.Redirect(
w,
r,
loginURL.String(),
http.StatusFound,
)
return
}
if x.IsJSONRequest(r) {
h.d.Writer().WriteError(w, r, errors.WithStack(ErrAlreadyLoggedIn()))
return
}
returnTo, redirErr := redir.SecureRedirectTo(
r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(ctx)),
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
)
if redirErr != nil {
h.d.SelfServiceErrorManager().Forward(ctx, w, r, redirErr)
return
}
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
return
}
redirTo := a.AppendTo(h.d.Config().SelfServiceFlowRegistrationUI(ctx)).String()
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), a, redirTo)
}
// Get Registration Flow Parameters
//
// swagger:parameters getRegistrationFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getRegistrationFlow struct {
// The Registration Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/registration?flow=abcde`).
//
// required: true
// in: query
ID string `json:"id"`
// 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/registration/flows frontend getRegistrationFlow
//
// # Get Registration Flow
//
// This endpoint returns a registration flow's context with, for example, error details and other information.
//
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
//
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
// and you need to forward the incoming HTTP Cookie header to this endpoint:
//
// ```js
// // pseudo-code example
// router.get('/registration', async function (req, res) {
// const flow = await client.getRegistrationFlow(req.header('cookie'), req.query['flow'])
//
// res.render('registration', flow)
// })
// ```
//
// This request may fail due to several reasons. The `error.id` can be one of:
//
// - `session_already_available`: The user is already signed in.
// - `self_service_flow_expired`: The flow is expired and you should request a new one.
//
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: registrationFlow
// 403: errorGeneric
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) getRegistrationFlow(w http.ResponseWriter, r *http.Request) {
ar, err := h.d.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("id")))
if err != nil {
// A flow that does not exist reports a disabled registration to not
// leak flow existence. Any other error is returned as-is, so that an
// infrastructure failure is not misreported as a disabled
// registration.
if errors.Is(err, sqlcon.ErrNoRows()) && !h.d.Config().SelfServiceFlowRegistrationEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(ErrRegistrationDisabled()))
return
}
h.d.Writer().WriteError(w, r, err)
return
}
// When registration is disabled, only flows that were converted from a
// login flow for account linking may be fetched.
if !ar.IsAccountLinking() && !h.d.Config().SelfServiceFlowRegistrationEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(ErrRegistrationDisabled()))
return
}
// Browser flows must include the CSRF token
//
// Resolves: https://github.com/ory/kratos/issues/1282
if ar.Type == flow.TypeBrowser && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken) {
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
return
}
if ar.ExpiresAt.Before(h.d.Clock().Now()) {
if ar.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(r.Context(), h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The registration flow has expired. Redirect the user to the registration flow init endpoint to initialize a new registration flow.").
WithDetail("redirect_to", redirectURL.String()).
WithDetail("return_to", ar.ReturnTo)))
return
}
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The registration flow has expired. Call the registration flow init API endpoint to initialize a new registration flow.").
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String())))
return
}
if ar.OAuth2LoginChallenge != "" {
hlr, err := h.d.Hydra().GetLoginRequest(r.Context(), string(ar.OAuth2LoginChallenge))
if err != nil {
// We don't redirect back to the third party on errors because Hydra doesn't
// give us the 3rd party return_uri when it redirects to the login UI.
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
ar.HydraLoginRequest = hlr
}
h.d.Writer().Write(w, r, ar)
}
// Update Registration Flow Parameters
//
// swagger:parameters updateRegistrationFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRegistrationFlow struct {
// The Registration Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/registration?flow=abcde`).
//
// required: true
// in: query
Flow string `json:"flow"`
// in: body
// required: true
Body updateRegistrationFlowBody
// 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 Registration Request Body
//
// swagger:model updateRegistrationFlowBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRegistrationFlowBody struct{}
// swagger:route POST /self-service/registration frontend updateRegistrationFlow
//
// # Update Registration Flow
//
// Use this endpoint to complete a registration flow by sending an identity's traits and password. This endpoint
// behaves differently for API and browser flows.
//
// API flows expect `application/json` to be sent in the body and respond with
// - HTTP 200 and a application/json body with the created identity success - if the session hook is configured the
// `session` and `session_token` will also be included;
// - HTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;
// - HTTP 400 on form validation errors.
//
// Browser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with
// - a HTTP 303 redirect to the post/after registration URL or the `return_to` value if it was set and if the registration succeeded;
// - a HTTP 303 redirect to the registration UI URL with the flow ID containing the validation errors otherwise.
//
// Browser flows with an accept header of `application/json` will not redirect but instead 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 400 on form validation errors.
//
// If this endpoint is called with `Accept: application/json` in the 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_already_available`: The user is already signed in.
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
// - `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 Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Schemes: http, https
//
// Consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Responses:
// 200: successfulNativeRegistration
// 303: emptyResponse
// 400: registrationFlow
// 410: errorGeneric
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-low
func (h *Handler) updateRegistrationFlow(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = semconv.ContextWithAttributes(
ctx,
attribute.String(events.AttributeKeySelfServiceStrategyUsed.String(), "registration"),
attribute.String(events.AttributeKeySelfServiceFlowName.String(), "registration"),
)
r = r.WithContext(ctx)
rid, err := flow.GetFlowID(r)
if err != nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, nil, "", node.DefaultGroup, err)
return
}
f, err := h.d.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), rid)
if err != nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, nil, "", node.DefaultGroup, err)
return
}
if err := h.d.SessionManager().SessionActiveForRequest(r.Context(), r); err == nil {
if f.Type == flow.TypeBrowser {
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()).String(), http.StatusSeeOther)
return
}
h.d.Writer().WriteError(w, r, errors.WithStack(ErrAlreadyLoggedIn()))
return
}
if err := f.Valid(h.d.Clock()); err != nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
return
}
i := identity.NewIdentity(f.IdentitySchema.ID(ctx, h.d.Config()))
var s Strategy
for _, ss := range h.d.AllRegistrationStrategies() {
if err := ss.Register(w, r, f, i); errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), ss.NodeGroup(), err)
return
}
s = ss
break
}
if s == nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(schema.NewNoRegistrationStrategyResponsible()))
return
}
if err := h.d.RegistrationExecutor().PostRegistrationHook(w, r, f, i, session.AuthenticationMethod{
Method: s.ID(),
AAL: identity.AuthenticatorAssuranceLevel1,
}); err != nil {
h.d.RegistrationFlowErrorHandler().WriteFlowError(w, r, f, s.ID(), s.NodeGroup(), err)
return
}
}

View File

@@ -0,0 +1,696 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/registration"
"github.com/ory/kratos/selfservice/strategy/oidc"
"github.com/ory/kratos/selfservice/strategy/password"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/urlx"
)
func init() {
corpx.RegisterFakes()
}
func TestHandlerRedirectOnAuthenticated(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
fakeHydra := hydra.NewFake()
reg.SetHydra(fakeHydra)
router := httprouterx.NewRouterPublic()
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
// Set it first as otherwise it will overwrite the ViperKeySelfServiceBrowserDefaultReturnTo key;
returnToTS := testhelpers.NewRedirTS(t, "return_to", conf)
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToTS.URL})
redirTS := testhelpers.NewRedirTS(t, "already authenticated", conf)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+registration.RouteInitBrowserFlow, nil))
assert.Contains(t, res.Request.URL.String(), redirTS.URL)
assert.EqualValues(t, "already authenticated", string(body))
})
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+registration.RouteInitAPIFlow, nil))
assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow)
assertx.EqualAsJSON(t, registration.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw))
})
t.Run("does redirect to return_to url on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+registration.RouteInitBrowserFlow+"?return_to="+returnToTS.URL, nil))
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
assert.EqualValues(t, "return_to", string(body))
})
t.Run("oauth2 with session and skip=false is redirected to login", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
fakeHydra.RequestURL = "https://www.ory.com/oauth2/auth?audience=&client_id=foo&login_verifier="
fakeHydra.Skip = false
client := testhelpers.NewClientWithCookies(t)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
_, res := testhelpers.MockMakeAuthenticatedRequestWithClient(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+registration.RouteInitBrowserFlow+"?login_challenge="+hydra.FakeValidLoginChallenge, nil), client)
assert.Contains(t, res.Header.Get("location"), login.RouteInitBrowserFlow)
})
t.Run("oauth2 with session and skip=true is accepted", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
fakeHydra.Skip = true
fakeHydra.RequestURL = "https://www.ory.com/oauth2/auth?audience=&client_id=foo&login_verifier="
client := testhelpers.NewClientWithCookies(t)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
_, res := testhelpers.MockMakeAuthenticatedRequestWithClient(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+registration.RouteInitBrowserFlow+"?login_challenge="+hydra.FakeValidLoginChallenge, nil), client)
assert.Contains(t, res.Header.Get("location"), hydra.FakePostLoginURL)
})
}
func TestInitFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword),
map[string]interface{}{"enabled": true})
router := httprouterx.NewRouterPublic()
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
registrationTS := testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
returnToTS := testhelpers.NewRedirTS(t, "return_to", conf)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "default", URL: "file://./stub/registration.schema.json"},
{ID: "email", URL: "file://./stub/registration.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://./stub/registration.phone.schema.json", SelfserviceSelectable: true},
{ID: "not-allowed", URL: "file://./stub/registration.schema.json"},
})
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "email")
errTS := testhelpers.NewErrorTestServer(t, reg)
assertion := func(body []byte, isForced, isApi bool) {
if isApi {
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
} else {
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
}
}
initAuthenticatedFlow := func(t *testing.T, isAPI bool, isSPA bool) (*http.Response, []byte) {
route := registration.RouteInitBrowserFlow
if isAPI {
route = registration.RouteInitAPIFlow
}
req := testhelpers.NewTestHTTPRequest(t, "GET", publicTS.URL+route, nil)
if isSPA {
req.Header.Set("Accept", "application/json")
}
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, req)
if isAPI {
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
}
return res, body
}
initFlowWithAccept := func(t *testing.T, query url.Values, isAPI bool, accept string) (*http.Response, []byte) {
route := registration.RouteInitBrowserFlow
if isAPI {
route = registration.RouteInitAPIFlow
}
c := publicTS.Client()
req, err := http.NewRequest("GET", publicTS.URL+route+"?"+query.Encode(), nil)
require.NoError(t, err)
if accept != "" {
req.Header.Set("Accept", accept)
}
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
initFlow := func(t *testing.T, query url.Values, isAPI bool) (*http.Response, []byte) {
return initFlowWithAccept(t, query, isAPI, "")
}
initSPAFlow := func(t *testing.T) (*http.Response, []byte) {
return initFlowWithAccept(t, url.Values{}, false, "application/json")
}
t.Run("suite=identity schema in query", func(t *testing.T) {
for _, tc := range []struct {
name string
query url.Values
wantErr bool
assert func(*testing.T, []byte)
}{{
name: "not-allowed",
query: url.Values{"identity_schema": {"not-allowed"}},
wantErr: true,
}, {
name: "not-found",
query: url.Values{"identity_schema": {"not-found"}},
wantErr: true,
}, {
name: "phone",
query: url.Values{"identity_schema": {"phone"}},
assert: func(t *testing.T, body []byte) {
assert.True(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.phone)").Exists(), "%s", body)
assert.False(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body)
},
}, {
name: "email",
query: url.Values{"identity_schema": {"email"}},
assert: func(t *testing.T, body []byte) {
assert.False(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.phone)").Exists(), "%s", body)
assert.True(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body)
},
}} {
t.Run("case="+tc.name, func(t *testing.T) {
t.Run("flow=api", func(t *testing.T) {
res, body := initFlow(t, tc.query, true)
if tc.wantErr {
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
return
}
tc.assert(t, body)
})
t.Run("flow=browser", func(t *testing.T) {
res, body := initFlow(t, tc.query, false)
if tc.wantErr {
require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
assert.EqualValues(t, "Bad Request", gjson.GetBytes(body, "status").String(), "%s", body)
return
}
tc.assert(t, body)
})
})
}
})
t.Run("flow=api", func(t *testing.T) {
t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{}, true)
assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow)
assertion(body, false, true)
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=returns a session token exchange code", func(t *testing.T) {
res, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), true)
assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow)
assertion(body, false, true)
assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, true, false)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, registration.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
t.Run("flow=browser", func(t *testing.T) {
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{}, false)
assertion(body, false, false)
assert.Contains(t, res.Request.URL.String(), registrationTS.URL)
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=never returns a session token exchange code", func(t *testing.T) {
_, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), false)
assertion(body, false, false)
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
})
t.Run("case=makes request with JSON", func(t *testing.T) {
res, body := initSPAFlow(t)
assertion(body, false, false)
assert.NotContains(t, res.Request.URL.String(), registrationTS.URL)
})
t.Run("case=redirects when already authenticated", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, false, false)
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
})
t.Run("case=responds with error if already authenticated and SPA", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, false, true)
assert.NotContains(t, res.Request.URL.String(), returnToTS.URL)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, registration.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
t.Run("case=relative redirect when self-service registration ui is a relative URL", func(t *testing.T) {
reg.Config().MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, "/registration-ts")
assert.Regexp(
t,
"^/registration-ts.*$",
testhelpers.GetSelfServiceRedirectLocation(t, publicTS.URL+registration.RouteInitBrowserFlow),
)
})
t.Run("case=redirects with 303", func(t *testing.T) {
c := &http.Client{}
// don't get the reference, instead copy the values, so we don't alter the client directly.
*c = *publicTS.Client()
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", publicTS.URL+registration.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)
})
})
}
func TestDisabledFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, false)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword),
map[string]interface{}{"enabled": true})
publicTS, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
errTS := testhelpers.NewErrorTestServer(t, reg)
makeRequest := func(t *testing.T, route string, isSPA bool) (*http.Response, []byte) {
c := publicTS.Client()
req, err := http.NewRequest("GET", publicTS.URL+route, nil)
require.NoError(t, err)
if isSPA {
req.Header.Set("Accept", "application/json")
}
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
t.Run("flow=api", func(t *testing.T) {
t.Run("case=init fails when flow disabled", func(t *testing.T) {
res, body := makeRequest(t, registration.RouteInitAPIFlow, false)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, registration.ErrRegistrationDisabled(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
t.Run("case=get flow fails when flow disabled", func(t *testing.T) {
res, body := makeRequest(t, registration.RouteGetFlow+"?id="+x.NewUUID().String(), false)
require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
assert.EqualValues(t, registration.ErrRegistrationDisabled().ReasonField, gjson.GetBytes(body, "reason").String(), "%s", body)
})
})
t.Run("flow=browser", func(t *testing.T) {
t.Run("case=init responds with error if flow disabled and SPA", func(t *testing.T) {
res, body := makeRequest(t, registration.RouteInitBrowserFlow, true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, registration.ErrRegistrationDisabled(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
t.Run("case=get flow responds with error if flow disabled and SPA", func(t *testing.T) {
res, body := makeRequest(t, registration.RouteGetFlow+"?id="+x.NewUUID().String(), true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, registration.ErrRegistrationDisabled(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
}
func TestAccountLinkingFlowWithRegistrationDisabled(t *testing.T) {
t.Parallel()
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, false)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword),
map[string]interface{}{"enabled": true})
publicTS, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
errTS := testhelpers.NewErrorTestServer(t, reg)
newAccountLinkingFlow := func(t *testing.T) *registration.Flow {
req := httptest.NewRequest("GET", publicTS.URL+registration.RouteInitAPIFlow, nil)
f, err := reg.RegistrationHandler().NewRegistrationFlow(httptest.NewRecorder(), req, flow.TypeAPI,
registration.WithFlowAccountLinking())
require.NoError(t, err)
return f
}
t.Run("case=creating an account linking flow succeeds", func(t *testing.T) {
f := newAccountLinkingFlow(t)
assert.True(t, f.IsAccountLinking())
})
t.Run("case=creating a regular flow still fails", func(t *testing.T) {
req := httptest.NewRequest("GET", publicTS.URL+registration.RouteInitAPIFlow, nil)
_, err := reg.RegistrationHandler().NewRegistrationFlow(httptest.NewRecorder(), req, flow.TypeAPI)
require.ErrorContains(t, err, registration.ErrRegistrationDisabled().Error())
})
t.Run("case=fetching an account linking flow succeeds", func(t *testing.T) {
f := newAccountLinkingFlow(t)
res, err := publicTS.Client().Get(publicTS.URL + registration.RouteGetFlow + "?id=" + f.ID.String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(t, f.ID.String(), gjson.GetBytes(body, "id").String(), "%s", body)
})
t.Run("case=fetching a regular flow still fails", func(t *testing.T) {
// A regular flow can exist while registration is disabled if it was
// created before registration was disabled.
req := httptest.NewRequest("GET", publicTS.URL+registration.RouteInitAPIFlow, nil)
f, err := registration.NewFlow(reg, req, flow.TypeAPI)
require.NoError(t, err)
require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(ctx, f))
res, err := publicTS.Client().Get(publicTS.URL + registration.RouteGetFlow + "?id=" + f.ID.String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
assert.EqualValues(t, registration.ErrRegistrationDisabled().ReasonField, gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("case=fetching a flow that does not exist still fails", func(t *testing.T) {
// Reporting "registration disabled" instead of "not found" keeps the
// existence of a flow from leaking.
res, err := publicTS.Client().Get(publicTS.URL + registration.RouteGetFlow + "?id=" + x.NewUUID().String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body)
assert.EqualValues(t, registration.ErrRegistrationDisabled().ReasonField, gjson.GetBytes(body, "reason").String(), "%s", body)
})
t.Run("case=fetching a flow does not report a broken database as registration disabled", func(t *testing.T) {
// This case needs a registry of its own because it breaks the database.
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, false)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
publicTS, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
errTS := testhelpers.NewErrorTestServer(t, reg)
// Drop the table the flow lookup reads so that it fails with an
// infrastructure error instead of "not found". The error persister
// keeps working, so a forward to the error UI stays observable.
require.NoError(t, reg.Persister().GetConnection(ctx).
RawQuery("DROP TABLE "+registration.Flow{}.TableName()).Exec())
res, err := publicTS.Client().Get(publicTS.URL + registration.RouteGetFlow + "?id=" + x.NewUUID().String())
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.NotContains(t, res.Request.URL.String(), errTS.URL,
"a database failure must not be forwarded to the error UI as a registration error: %s", body)
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "%s", body)
})
}
func TestGetFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
returnToTS := testhelpers.NewRedirTS(t, "return_to", conf)
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "email", URL: "file://./stub/registration.schema.json", SelfserviceSelectable: true},
{ID: "phone", URL: "file://./stub/registration.phone.schema.json", SelfserviceSelectable: true},
{ID: "not-allowed", URL: "file://./stub/registration.schema.json"},
})
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "email")
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword),
map[string]interface{}{"enabled": true})
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRedirTS(t, "", conf)
setupRegistrationUI := func(t *testing.T, c *http.Client) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+registration.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
require.NoError(t, err)
}))
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, ts.URL)
return ts
}
t.Run("case=valid", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
_ = setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow)
assert.NotEmpty(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body)
assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), gjson.GetBytes(body, "id").String(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), public.URL, "%s", body)
})
t.Run("case=csrf cookie missing", func(t *testing.T) {
client := testhelpers.NewTestClient(t)
_ = setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow)
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=expired", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow)
// Expire the flow
f, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RegistrationFlowPersister().UpdateRegistrationFlow(context.Background(), f))
res, body := testhelpers.EasyGet(t, client, public.URL+registration.RouteGetFlow+"?id="+f.ID.String())
assert.EqualValues(t, http.StatusGone, res.StatusCode)
assert.Equal(t, public.URL+registration.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
})
t.Run("case=expired with return_to and identity_schema", func(t *testing.T) {
returnTo := returnToTS.URL
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
client := testhelpers.NewClientWithCookies(t)
setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow+
"?return_to="+returnTo+
"&identity_schema=email")
// Expire the flow
f, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RegistrationFlowPersister().UpdateRegistrationFlow(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", public.URL, registration.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 := public.URL + registration.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := client.PostForm(u, url.Values{"method": {"password"}, "csrf_token": {f.CSRFToken}, "password": {"password"}, "traits.email": {"email@ory.sh"}})
require.NoError(t, err)
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
f, err = reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
require.NoError(t, err)
assert.Equal(t, public.URL+registration.RouteInitBrowserFlow+"?return_to="+returnTo+"&identity_schema=email", f.RequestURL)
})
t.Run("case=not found", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRegistrationUI(t, client)
res, _ := testhelpers.EasyGet(t, client, public.URL+registration.RouteGetFlow+"?id="+x.NewUUID().String())
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
})
}
// This test verifies that the password method is still executed even if the
// oidc strategy is ordered before the password strategy
// when submitting the form with both `method=password` and `provider=google`.
func TestOIDCStrategyOrder(t *testing.T) {
t.Logf("This test has been set up to validate the current incorrect `oidc` behaviour. When submitting the form, the `oidc` strategy is executed first, even if the method is set to `password`.")
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
// reorder the strategies
reg.WithSelfserviceStrategies(t, []any{
oidc.NewStrategy(reg, oidc.ForCredentialType(identity.CredentialsTypeOIDC)),
password.NewStrategy(reg),
})
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword),
map[string]interface{}{"enabled": true})
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC),
map[string]interface{}{"enabled": true})
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth),
map[string]interface{}{"passwordless_enabled": true})
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{
{
ID: "google",
Provider: "google",
ClientID: "1234",
ClientSecret: "1234",
},
}})
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRedirTS(t, "", conf)
setupRegistrationUI := func(t *testing.T, c *http.Client) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+registration.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
require.NoError(t, err)
}))
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, ts.URL)
return ts
}
t.Run("case=accept `password` method while including `provider:google`", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
_ = setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow)
flow := gjson.GetBytes(body, "id").String()
csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
email := faker.Email()
payload := json.RawMessage(`{"traits": {"email": "` + email + `"},"method": "password","password": "asdasdasdsa21312@#!@%","provider": "google","csrf_token": "` + csrfToken + `"}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, public.URL+registration.RouteSubmitFlow+"?flow="+flow, bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", ioutilx.MustReadAll(resp.Body))
verifiableAddress, err := reg.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, email)
require.NoError(t, err)
require.Equal(t, strings.ToLower(email), verifiableAddress.Value)
id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, verifiableAddress.IdentityID)
require.NoError(t, err)
require.NotNil(t, id.ID)
_, ok := id.GetCredentials(identity.CredentialsTypePassword)
require.True(t, ok)
})
t.Run("case=accept oidc flow with just `provider:google`", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
_ = setupRegistrationUI(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+registration.RouteInitBrowserFlow)
flow := gjson.GetBytes(body, "id").String()
csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
payload := json.RawMessage(`{"provider": "google","csrf_token": "` + csrfToken + `"}`)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, public.URL+registration.RouteSubmitFlow+"?flow="+flow, bytes.NewBuffer(payload))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Containsf(t,
gjson.GetBytes(b, "error.reason").String(),
"In order to complete this flow please redirect the browser to: https://accounts.google.com/o/oauth2/v2/auth",
"%s", b,
)
})
}

View File

@@ -0,0 +1,396 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
)
type (
PreHookExecutor interface {
ExecuteRegistrationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
}
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
PostHookPostPersistExecutor interface {
ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
}
PostHookPostPersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
PostHookPrePersistExecutor interface {
ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
}
PostHookPrePersistExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
HooksProvider interface {
PreRegistrationHooks(ctx context.Context) ([]PreHookExecutor, error)
PostRegistrationPrePersistHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]PostHookPrePersistExecutor, error)
PostRegistrationPostPersistHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]PostHookPostPersistExecutor, error)
}
)
func ExecutorNames[T any](e []T) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func (f PreHookExecutorFunc) ExecuteRegistrationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
return f(w, r, a)
}
func (f PostHookPostPersistExecutorFunc) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
return f(w, r, a, s)
}
func (f PostHookPrePersistExecutorFunc) ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
return f(w, r, a, i)
}
type (
executorDependencies interface {
config.Provider
identity.ManagementProvider
identity.PrivilegedPoolProvider
identity.ValidationProvider
login.FlowPersistenceProvider
login.StrategyProvider
session.PersistenceProvider
session.ManagementProvider
HooksProvider
FlowPersistenceProvider
hydra.Provider
nosurfx.CSRFTokenGeneratorProvider
httpx.ClientProvider
logrusx.Provider
httpx.WriterProvider
otelx.Provider
sessiontokenexchange.PersistenceProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
RegistrationExecutor() *HookExecutor
}
)
func NewHookExecutor(d executorDependencies) *HookExecutor {
return &HookExecutor{d: d}
}
// PostRegistrationHook finalizes a successful registration. The
// authMethod argument describes how the user authenticated during
// registration and is recorded on the resulting session verbatim. For
// OIDC registrations it carries the upstream acr/amr claims and the
// provider-computed AAL; for other strategies it is typically
// `{Method: strategy.ID(), AAL: AuthenticatorAssuranceLevel1}`.
func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Request, registrationFlow *Flow, i *identity.Identity, authMethod session.AuthenticationMethod) (err error) {
ct := authMethod.Method
ctx := r.Context()
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostRegistrationHook")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("Running PostRegistrationPrePersistHooks.")
preHooks, err := e.d.PostRegistrationPrePersistHooks(ctx, ct)
if err != nil {
return err
}
for k, executor := range preHooks {
if err := executor.ExecutePostRegistrationPrePersistHook(w, r, registrationFlow, i); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(preHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("A ExecutePostRegistrationPrePersistHook hook aborted early.")
return nil
}
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(preHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
WithError(err).
Error("ExecutePostRegistrationPostPersistHook hook failed with an error.")
traits := i.Traits
return flow.HandleHookError(w, r, registrationFlow, traits, ct.ToUiNodeGroup(), err, e.d, e.d)
}
e.d.Logger().WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(preHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("ExecutePostRegistrationPrePersistHook completed successfully.")
}
// We need to make sure that the identity has a valid schema before passing it down to the identity pool.
if err := e.d.IdentityValidator().Validate(ctx, i); err != nil {
return err
}
if registrationFlow.IsAccountLinking() && !e.d.Config().SelfServiceFlowRegistrationEnabled(ctx) {
// This flow was created internally from a login flow to link a new
// credential to an existing identity while registration is disabled.
// It must never create a new identity, so require that a conflicting
// identity exists before attempting to persist. The create below then
// fails with a unique violation, which triggers the account linking
// conversion back to a login flow.
if _, _, _, err := e.d.IdentityManager().ConflictingIdentity(ctx, i); err != nil {
if errors.Is(err, sqlcon.ErrNoRows()) {
return errors.WithStack(ErrRegistrationDisabled())
}
return err
}
}
// We're now creating the identity because any of the hooks could trigger a "redirect" or a "session" which
// would imply that the identity has to exist already.
if err := e.d.IdentityManager().Create(ctx, i); err != nil {
if errors.Is(err, sqlcon.ErrUniqueViolation()) {
strategy, err := e.d.AllLoginStrategies().Strategy(ct)
if err != nil {
return err
}
if strategy, ok := strategy.(login.LinkableStrategy); ok {
duplicateIdentifier, err := e.getDuplicateIdentifier(ctx, i)
if err != nil {
return err
}
if err := strategy.SetDuplicateCredentials(
registrationFlow,
duplicateIdentifier,
i.Credentials[ct],
authMethod.Provider,
); err != nil {
return err
}
}
}
// Restore submitted trait values to the flow UI so they are preserved in the error response.
for _, n := range container.NewFromJSON("", node.DefaultGroup, json.RawMessage(i.Traits), "traits").Nodes {
registrationFlow.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
return err
}
// At this point the identity is already created and will not be rolled back, so
// we want all PostPersist hooks to be able to continue even when the client cancels the request.
ctx = context.WithoutCancel(ctx)
r = r.WithContext(ctx)
// Verify the redirect URL before we do any other processing.
c := e.d.Config()
returnTo, err := redir.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectReturnTo(registrationFlow.ReturnTo),
redir.SecureRedirectUseSourceURL(registrationFlow.RequestURL),
redir.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowRegistrationReturnTo(ctx, ct.String())),
)
if err != nil {
return err
}
span.SetAttributes(otelx.StringAttrs(map[string]string{
"return_to": returnTo.String(),
"flow_type": string(registrationFlow.Type),
"redirect_reason": "registration successful",
})...)
if registrationFlow.Type == flow.TypeBrowser && x.IsJSONRequest(r) {
registrationFlow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String()))
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
Info("A new identity has registered using self-service registration.")
span.AddEvent(events.NewRegistrationSucceeded(ctx, registrationFlow.ID, i.ID, string(registrationFlow.Type), ct.String(), authMethod.Provider))
s := session.NewInactiveSession()
s.CompletedLoginForMethod(authMethod)
if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil {
return err
}
// We persist the session here so that subsequent hooks (like verification) can use it.
if err := e.d.SessionPersister().UpsertSession(ctx, s); err != nil {
return err
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("Running PostRegistrationPostPersistHooks.")
postHooks, err := e.d.PostRegistrationPostPersistHooks(ctx, ct)
if err != nil {
return err
}
for k, executor := range postHooks {
if err := executor.ExecutePostRegistrationPostPersistHook(w, r, registrationFlow, s); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("A ExecutePostRegistrationPostPersistHook hook aborted early.")
span.SetAttributes(attribute.String("redirect_reason", "aborted by hook"), attribute.String("executor", fmt.Sprintf("%T", executor)))
return nil
}
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
WithError(err).
Error("ExecutePostRegistrationPostPersistHook hook failed with an error.")
span.SetAttributes(attribute.String("redirect_reason", "hook error"), attribute.String("executor", fmt.Sprintf("%T", executor)))
traits := i.Traits
return flow.HandleHookError(w, r, registrationFlow, traits, ct.ToUiNodeGroup(), err, e.d, e.d)
}
e.d.Logger().WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", ExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", ct).
Debug("ExecutePostRegistrationPostPersistHook completed successfully.")
}
e.d.Logger().
WithRequest(r).
WithField("flow_method", ct).
WithField("identity_id", i.ID).
Debug("Post registration execution hooks completed successfully.")
if registrationFlow.Type == flow.TypeAPI || x.IsJSONRequest(r) {
span.SetAttributes(attribute.String("flow_type", string(flow.TypeAPI)))
if registrationFlow.IDToken != "" {
// We don't want to redirect with the code, if the flow was submitted with an ID token.
// This is the case for Sign in with native Apple SDK or Google SDK.
} else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, registrationFlow, s.ID, ct.ToUiNodeGroup()); err != nil {
return errors.WithStack(err)
} else if handled {
return nil
}
e.d.Writer().Write(w, r, &APIFlowResponse{
Identity: i,
ContinueWith: registrationFlow.ContinueWith(),
})
return nil
}
finalReturnTo := returnTo.String()
if registrationFlow.OAuth2LoginChallenge != "" {
if registrationFlow.ReturnToVerification != "" {
// Special case: If Kratos is used as a login UI *and* we want to show the verification UI,
// redirect to the verification URL first and then return to Hydra.
finalReturnTo = registrationFlow.ReturnToVerification
} else {
callbackURL, err := e.d.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(registrationFlow.OAuth2LoginChallenge),
IdentityID: i.ID.String(),
SessionID: s.ID.String(),
AuthenticationMethods: s.AMR,
})
if err != nil {
return err
}
finalReturnTo = callbackURL
}
span.SetAttributes(attribute.String("redirect_reason", "oauth2 login challenge"))
} else if registrationFlow.ReturnToVerification != "" {
finalReturnTo = registrationFlow.ReturnToVerification
span.SetAttributes(attribute.String("redirect_reason", "verification requested"))
}
span.SetAttributes(attribute.String("return_to", finalReturnTo))
redir.ContentNegotiationRedirection(w, r, s.Declassified(), e.d.Writer(), finalReturnTo)
return nil
}
func (e *HookExecutor) getDuplicateIdentifier(ctx context.Context, i *identity.Identity) (string, error) {
_, id, _, err := e.d.IdentityManager().ConflictingIdentity(ctx, i)
if err != nil {
return "", err
}
return id, nil
}
func (e *HookExecutor) PreRegistrationHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
hooks, err := e.d.PreRegistrationHooks(r.Context())
if err != nil {
return err
}
for _, executor := range hooks {
if err := executor.ExecuteRegistrationPreHook(w, r, a); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,404 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration_test
import (
"context"
"fmt"
"net/http"
nethttptest "net/http/httptest"
"net/url"
"testing"
"github.com/gobuffalo/httptest"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"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/registration"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
)
func TestRegistrationExecutor(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, strategy := range identity.AllCredentialTypes {
strategy := strategy.String()
t.Run("strategy="+strategy, func(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t)
reg.SetHydra(hydra.NewFake())
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL)
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type, flowCallbacks ...func(*registration.Flow)) *httptest.Server {
router := http.NewServeMux()
handleErr := testhelpers.SelfServiceHookRegistrationErrorHandler
router.HandleFunc("GET /registration/pre", func(w http.ResponseWriter, r *http.Request) {
f, err := registration.NewFlow(reg, r, ft)
require.NoError(t, err)
if handleErr(t, w, r, reg.RegistrationHookExecutor().PreRegistrationHook(w, r, f)) {
_, _ = w.Write([]byte("ok"))
}
})
router.HandleFunc("GET /registration/post", func(w http.ResponseWriter, r *http.Request) {
if i == nil {
i = testhelpers.SelfServiceHookFakeIdentity(t)
}
regFlow, err := registration.NewFlow(reg, r, ft)
require.NoError(t, err)
regFlow.RequestURL = x.RequestURL(r).String()
for _, callback := range flowCallbacks {
callback(regFlow)
}
_ = handleErr(t, w, r, reg.RegistrationHookExecutor().PostRegistrationHook(w, r, regFlow, i, session.AuthenticationMethod{
Method: identity.CredentialsType(strategy),
AAL: identity.AuthenticatorAssuranceLevel1,
}))
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
return ts
}
makeRequestPost := testhelpers.SelfServiceMakeRegistrationPostHookRequest
viperSetPost := testhelpers.SelfServiceHookRegistrationViperSetPost
t.Run("method=PostRegistrationHook", func(t *testing.T) {
t.Run("case=pass without hooks", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, body := makeRequestPost(t, ts, false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
actual, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, actual.Traits, i.Traits)
})
t.Run("case=pass without hooks if ajax client", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, body := makeRequestPost(t, ts, true, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), ts.URL)
assert.JSONEq(t, fmt.Sprintf(`[{"action":"redirect_browser_to","redirect_browser_to":"%s"}]`, returnToServer.URL), gjson.Get(body, "continue_with").Raw)
actual, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, actual.Traits, i.Traits)
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("case=fail if hooks fail", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRegistrationPrePersistHook": "abort"}`)}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(t, "", body)
_, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing)
require.Error(t, err)
})
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})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{"return_to": {returnToServer.URL + "/kratos"}})
require.EqualValuesf(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.SelfServiceHookRegistrationSetDefaultRedirectToStrategy(t, conf, strategy, returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(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))
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToServer.URL + "/kratos"})
testhelpers.SelfServiceHookRegistrationSetDefaultRedirectTo(t, conf, returnToServer.URL+"/not-kratos")
testhelpers.SelfServiceHookRegistrationSetDefaultRedirectToStrategy(t, conf, strategy, returnToServer.URL+"/kratos")
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(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(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
})
t.Run("case=send a json response for API clients", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
res, body := makeRequestPost(t, newServer(t, nil, flow.TypeAPI), true, url.Values{})
require.EqualValuesf(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.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.NotEmpty(t, gjson.Get(body, "identity.id"))
assert.Empty(t, gjson.Get(body, "session.token"))
assert.Empty(t, gjson.Get(body, "session_token"))
})
t.Run("case=should redirect to verification UI if show_verification_ui hook is set", func(t *testing.T) {
verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg)
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{
{
"hook": hook.KeyVerificationUI,
},
})
i := testhelpers.SelfServiceHookFakeIdentity(t)
i.Traits = identity.Traits(`{"email": "verifiable@ory.sh"}`)
res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), verificationTS.URL)
assert.NotEmpty(t, res.Request.URL.Query().Get("flow"))
})
t.Run("case=should redirect to verification UI if there is a login_challenge", func(t *testing.T) {
verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg)
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{{
"hook": hook.KeyVerificationUI,
}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
i.Traits = identity.Traits(`{"email": "verifiable-valid-login_challenge@ory.sh"}`)
withOAuthChallenge := func(f *registration.Flow) {
f.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge
}
res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser, withOAuthChallenge), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), verificationTS.URL)
flowID := res.Request.URL.Query().Get("flow")
require.NotEmpty(t, flowID)
flow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, uuid.Must(uuid.FromString(flowID)))
require.NoError(t, err)
assert.Equal(t, hydra.FakeValidLoginChallenge, flow.OAuth2LoginChallenge.String())
})
t.Run("case=should redirect to first verification UI if show_verification_ui hook is set and multiple verifiable addresses", func(t *testing.T) {
verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg)
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{
{
"hook": hook.KeyVerificationUI,
},
})
i := testhelpers.SelfServiceHookFakeIdentity(t)
i.SchemaID = testhelpers.UseIdentitySchema(t, conf, "file://./stub/registration-multi-email.schema.json")
i.Traits = identity.Traits(`{"emails": ["one@ory.sh", "two@ory.sh"]}`)
res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{})
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), verificationTS.URL)
assert.NotEmpty(t, res.Request.URL.Query().Get("flow"))
})
t.Run("case=should still sent session if show_verification_ui is set after session hook", func(t *testing.T) {
verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg)
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{
{
"hook": hook.KeyVerificationUI,
},
{
"hook": hook.KeySessionIssuer,
},
})
i := testhelpers.SelfServiceHookFakeIdentity(t)
i.Traits = identity.Traits(`{"email": "verifiable4@ory.sh"}`)
jar := testhelpers.EasyCookieJar(t, nil)
s := newServer(t, i, flow.TypeBrowser)
s.Client().Jar = jar
res, _ := makeRequestPost(t, s, false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), verificationTS.URL)
assert.NotEmpty(t, res.Request.URL.Query().Get("flow"))
u, err := url.Parse(s.URL)
require.NoError(t, err)
cookies := jar.Cookies(u)
require.Len(t, cookies, 1)
assert.Equal(t, "ory_kratos_session", cookies[0].Name)
})
})
t.Run("case=preserve trait values on duplicate credential error", func(t *testing.T) {
t.Parallel()
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
tosSchema := testhelpers.UseIdentitySchema(t, conf, "file://./stub/registration-tos.schema.json")
// Create an existing identity with a known email so the next registration triggers a unique violation.
existing := &identity.Identity{
SchemaID: tosSchema,
Traits: identity.Traits(`{"email":"duplicate@ory.sh"}`),
State: identity.StateActive,
}
existing.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Type: identity.CredentialsTypePassword,
Identifiers: []string{"duplicate@ory.sh"},
Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$abc$def"}`),
})
require.NoError(t, reg.IdentityManager().Create(context.Background(), existing))
// Build a new identity with the same email and tos=true.
duplicate := testhelpers.SelfServiceHookFakeIdentity(t)
duplicate.SchemaID = tosSchema
duplicate.Traits = identity.Traits(`{"email":"duplicate@ory.sh","tos":true}`)
duplicate.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Type: identity.CredentialsTypePassword,
Identifiers: []string{"duplicate@ory.sh"},
Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$abc$def"}`),
})
regFlow, err := registration.NewFlow(reg, nethttptest.NewRequest("GET", returnToServer.URL, nil), flow.TypeBrowser)
require.NoError(t, err)
regFlow.RequestURL = returnToServer.URL
// Add a checkbox node for traits.tos (simulating PopulateRegistrationMethod from the profile strategy).
regFlow.UI.Nodes.Upsert(node.NewInputField("traits.tos", false, node.DefaultGroup, node.InputAttributeTypeCheckbox))
err = reg.RegistrationHookExecutor().PostRegistrationHook(
nethttptest.NewRecorder(), nethttptest.NewRequest("GET", returnToServer.URL, nil),
regFlow, duplicate, session.AuthenticationMethod{
Method: identity.CredentialsTypePassword,
AAL: identity.AuthenticatorAssuranceLevel1,
},
)
require.Error(t, err)
// The TOS checkbox node must retain the submitted value (true) from the identity traits.
tosNode := regFlow.UI.Nodes.Find("traits.tos")
require.NotNil(t, tosNode, "traits.tos node must exist in the flow UI")
assert.Equal(t, true, tosNode.GetValue(), "traits.tos must be true after duplicate credential error")
})
for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(kind)+"/method=PreRegistrationHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceRegistrationBeforeHooks,
testhelpers.SelfServiceMakeRegistrationPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, nil, kind)
},
conf,
))
}
})
}
}
func TestPostRegistrationHookAccountLinkingOnly(t *testing.T) {
t.Parallel()
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
reg.SetHydra(hydra.NewFake())
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL)
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, false)
newIdentityWithEmail := func(email string) *identity.Identity {
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email":"` + email + `"}`)
i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
Identifiers: []string{email},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$fakefakefakefakefakefa"}`),
})
return i
}
runPostRegistrationHook := func(t *testing.T, i *identity.Identity) error {
r := httptest.NewRequest("GET", "/registration/post", nil)
r = r.WithContext(ctx)
regFlow, err := registration.NewFlow(reg, r, flow.TypeAPI)
require.NoError(t, err)
registration.WithFlowAccountLinking()(regFlow)
return reg.RegistrationHookExecutor().PostRegistrationHook(httptest.NewRecorder(), r, regFlow, i, session.AuthenticationMethod{
Method: identity.CredentialsTypePassword,
AAL: identity.AuthenticatorAssuranceLevel1,
})
}
t.Run("case=rejects a new identity when registration is disabled", func(t *testing.T) {
email := "linking-guard-new@ory.sh"
err := runPostRegistrationHook(t, newIdentityWithEmail(email))
require.ErrorContains(t, err, registration.ErrRegistrationDisabled().Error())
_, _, err = reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
t.Run("case=proceeds into the duplicate conflict when an identity exists", func(t *testing.T) {
email := "linking-guard-existing@ory.sh"
existing := newIdentityWithEmail(email)
require.NoError(t, reg.IdentityManager().Create(ctx, existing))
err := runPostRegistrationHook(t, newIdentityWithEmail(email))
// The guard must let the create attempt happen so that the unique
// violation triggers the account linking conversion.
require.ErrorIs(t, err, sqlcon.ErrUniqueViolation())
})
}

View File

@@ -0,0 +1,29 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"net/http"
"github.com/ory/kratos/selfservice/flow"
)
var organizationFilter = []StrategyFilter{func(s Strategy) bool {
a, b := s.(flow.OrganizationImplementor)
return b && a.SupportsOrganizations()
}}
func PrepareOrganizations(r *http.Request, f *Flow) []StrategyFilter {
if f.OrganizationID.Valid {
return organizationFilter
}
orgID := flow.ParseOrganizationFromURLQuery(r.Context(), r.URL.Query())
if !orgID.Valid {
return []StrategyFilter{}
}
f.OrganizationID = orgID
return organizationFilter
}

View File

@@ -0,0 +1,56 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
)
func TestPrepareOrganizations(t *testing.T) {
tests := []struct {
name string
flow *Flow
query url.Values
expectedFilter []StrategyFilter
}{
{
name: "valid organization ID in flow",
flow: &Flow{OrganizationID: uuid.NullUUID{UUID: uuid.FromStringOrNil("123e4567-e89b-12d3-a456-426614174000"), Valid: true}},
query: url.Values{},
expectedFilter: organizationFilter,
},
{
name: "valid organization ID in query",
flow: &Flow{},
query: url.Values{"organization": {"123e4567-e89b-12d3-a456-426614174000"}},
expectedFilter: organizationFilter,
},
{
name: "invalid organization ID in query",
flow: &Flow{},
query: url.Values{"organization": {"invalid-uuid"}},
expectedFilter: []StrategyFilter{},
},
{
name: "missing organization ID",
flow: &Flow{},
query: url.Values{},
expectedFilter: []StrategyFilter{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{URL: &url.URL{RawQuery: tt.query.Encode()}}
result := PrepareOrganizations(req.WithContext(context.Background()), tt.flow)
assert.Equal(t, tt.expectedFilter, result)
})
}
}

View File

@@ -0,0 +1,22 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type FlowPersister interface {
UpdateRegistrationFlow(context.Context, *Flow) error
CreateRegistrationFlow(context.Context, *Flow) error
GetRegistrationFlow(context.Context, uuid.UUID) (*Flow, error)
DeleteExpiredRegistrationFlows(context.Context, time.Time, int) error
}
type FlowPersistenceProvider interface {
RegistrationFlowPersister() FlowPersister
}

View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
)
// The Response for Registration Flows via API
//
// swagger:model successfulNativeRegistration
type APIFlowResponse struct {
// The Session Token
//
// This field is only set when the session hook is configured as a post-registration hook.
//
// A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization
// Header:
//
// Authorization: bearer ${session-token}
//
// The session token is only issued for API flows, not for Browser flows!
Token string `json:"session_token,omitempty"`
// The Session
//
// This field is only set when the session hook is configured as a post-registration hook.
//
// The session contains information about the user, the session device, and so on.
// This is only available for API flows, not for Browser flows!
Session *session.Session `json:"session,omitempty"`
// The Identity
//
// The identity that just signed up.
//
// required: true
Identity *identity.Identity `json:"identity"`
// Contains a list of actions, that could follow this flow
//
// It can, for example, this will contain a reference to the verification flow, created as part of the user's
// registration or the token of the session.
//
// required: false
ContinueWith []flow.ContinueWith `json:"continue_with"`
}

View File

@@ -0,0 +1,34 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
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.OpenIDConnectGroup,
node.SAMLGroup,
node.DefaultGroup,
node.WebAuthnGroup,
node.PasskeyGroup,
node.CodeGroup,
node.PasswordGroup,
node.CaptchaGroup,
node.ProfileGroup,
}),
node.SortUpdateOrder(node.PasswordLoginOrder),
node.SortUseOrderAppend([]string{
// WebAuthn
node.WebAuthnRemove,
node.WebAuthnRegisterDisplayName,
node.WebAuthnRegister,
}),
)
}

View File

@@ -0,0 +1,44 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"embed"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/snapshotx"
)
//go:embed fixtures/sort/*
var sortFixtures embed.FS
func TestSortNodes(t *testing.T) {
ctx := context.Background()
// TODO add more test cases.
entries, err := sortFixtures.ReadDir("fixtures/sort")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
t.Run("case="+entry.Name(), func(t *testing.T) {
toSort, err := sortFixtures.ReadFile("fixtures/sort/" + entry.Name())
require.NoError(t, err)
var n node.Nodes
require.NoError(t, json.Unmarshal(toSort, &n))
require.NoError(t, SortNodes(ctx, n, "file://fixtures/sort.schema.json"))
snapshotx.SnapshotT(t, n)
})
}
}

View File

@@ -0,0 +1,15 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import "github.com/ory/kratos/selfservice/flow"
// State represents the state of this request:
//
// - choose_method: ask the user to choose a method (e.g. registration with email)
// - sent_email: the email has been sent to the user
// - passed_challenge: the request was successful and the registration challenge was passed.
//
// swagger:model registrationFlowState
type State = flow.State

View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package registration
import (
"context"
"net/http"
"github.com/ory/kratos/ui/node"
"github.com/pkg/errors"
"github.com/ory/kratos/identity"
)
type Strategy interface {
ID() identity.CredentialsType
NodeGroup() node.UiNodeGroup
Register(w http.ResponseWriter, r *http.Request, f *Flow, i *identity.Identity) (err error)
}
type Strategies []Strategy
func (s Strategies) Strategy(id identity.CredentialsType) (Strategy, error) {
ids := make([]identity.CredentialsType, len(s))
for k, ss := range s {
ids[k] = ss.ID()
if ss.ID() == id {
return ss, nil
}
}
return nil, errors.Errorf(`unable to find strategy for %s have %v`, id, ids)
}
func (s Strategies) MustStrategy(id identity.CredentialsType) Strategy {
strategy, err := s.Strategy(id)
if err != nil {
panic(err)
}
return strategy
}
type StrategyFilter func(strategy Strategy) bool
type StrategyProvider interface {
RegistrationStrategies(ctx context.Context, filters ...StrategyFilter) Strategies
AllRegistrationStrategies() Strategies
}

Some files were not shown because too many files have changed in this diff Show More