snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
29
selfservice/flow/registration/decoder.go
Normal file
29
selfservice/flow/registration/decoder.go
Normal 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())
|
||||
}
|
||||
218
selfservice/flow/registration/error.go
Normal file
218
selfservice/flow/registration/error.go
Normal 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)
|
||||
}
|
||||
}
|
||||
309
selfservice/flow/registration/error_test.go
Normal file
309
selfservice/flow/registration/error_test.go
Normal 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 = ®istration.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
29
selfservice/flow/registration/fixtures/sort.schema.json
Normal file
29
selfservice/flow/registration/fixtures/sort.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"foobar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
selfservice/flow/registration/fixtures/sort/1.json
Normal file
81
selfservice/flow/registration/fixtures/sort/1.json
Normal 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"
|
||||
}
|
||||
]
|
||||
308
selfservice/flow/registration/flow.go
Normal file
308
selfservice/flow/registration/flow.go
Normal 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,
|
||||
}
|
||||
}
|
||||
171
selfservice/flow/registration/flow_test.go
Normal file
171
selfservice/flow/registration/flow_test.go
Normal 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 := ®istration.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: ®istration.Flow{ExpiresAt: time.Now().Add(time.Hour), IssuedAt: time.Now().Add(-time.Minute)},
|
||||
valid: true,
|
||||
},
|
||||
{r: ®istration.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 := ®istration.Flow{Type: ft}
|
||||
assert.Equal(t, ft, r.GetType())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequestURL(t *testing.T) {
|
||||
expectedURL := "http://foo/bar/baz"
|
||||
f := ®istration.Flow{RequestURL: expectedURL}
|
||||
assert.Equal(t, expectedURL, f.GetRequestURL())
|
||||
}
|
||||
|
||||
func TestFlowEncodeJSON(t *testing.T) {
|
||||
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, ®istration.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
|
||||
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, ®istration.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 := ®istration.Flow{ReturnTo: "/foo"}
|
||||
f.SetReturnTo()
|
||||
assert.Equal(t, "/foo", f.ReturnTo)
|
||||
|
||||
f = ®istration.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
|
||||
f.SetReturnTo()
|
||||
assert.Equal(t, "/bar", f.ReturnTo)
|
||||
}
|
||||
|
||||
func TestFlowAccountLinkingMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := ®istration.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())
|
||||
}
|
||||
760
selfservice/flow/registration/handler.go
Normal file
760
selfservice/flow/registration/handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
696
selfservice/flow/registration/handler_test.go
Normal file
696
selfservice/flow/registration/handler_test.go
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
396
selfservice/flow/registration/hook.go
Normal file
396
selfservice/flow/registration/hook.go
Normal 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
|
||||
}
|
||||
404
selfservice/flow/registration/hook_test.go
Normal file
404
selfservice/flow/registration/hook_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
29
selfservice/flow/registration/oragnizations.go
Normal file
29
selfservice/flow/registration/oragnizations.go
Normal 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
|
||||
}
|
||||
56
selfservice/flow/registration/organizations_test.go
Normal file
56
selfservice/flow/registration/organizations_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
22
selfservice/flow/registration/persistence.go
Normal file
22
selfservice/flow/registration/persistence.go
Normal 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
|
||||
}
|
||||
50
selfservice/flow/registration/session.go
Normal file
50
selfservice/flow/registration/session.go
Normal 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"`
|
||||
}
|
||||
34
selfservice/flow/registration/sort.go
Normal file
34
selfservice/flow/registration/sort.go
Normal 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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
44
selfservice/flow/registration/sort_test.go
Normal file
44
selfservice/flow/registration/sort_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
15
selfservice/flow/registration/state.go
Normal file
15
selfservice/flow/registration/state.go
Normal 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
|
||||
50
selfservice/flow/registration/strategy.go
Normal file
50
selfservice/flow/registration/strategy.go
Normal 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
|
||||
}
|
||||
34
selfservice/flow/registration/strategy_form_hydrator.go
Normal file
34
selfservice/flow/registration/strategy_form_hydrator.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package registration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderr "errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var ErrBreakRegistrationPopulate = stderr.New("skip rest of registration form population")
|
||||
|
||||
type UnifiedFormHydrator interface {
|
||||
PopulateRegistrationMethod(r *http.Request, sr *Flow) error
|
||||
}
|
||||
|
||||
type FormHydratorOptions struct {
|
||||
WithTraits json.RawMessage
|
||||
}
|
||||
|
||||
type FormHydratorModifier func(o *FormHydratorOptions)
|
||||
|
||||
func WithTraits(traits json.RawMessage) FormHydratorModifier {
|
||||
return func(o *FormHydratorOptions) {
|
||||
o.WithTraits = traits
|
||||
}
|
||||
}
|
||||
|
||||
type FormHydrator interface {
|
||||
UnifiedFormHydrator
|
||||
PopulateRegistrationMethodCredentials(r *http.Request, sr *Flow, options ...FormHydratorModifier) error
|
||||
PopulateRegistrationMethodProfile(r *http.Request, sr *Flow, options ...FormHydratorModifier) error
|
||||
}
|
||||
11
selfservice/flow/registration/stub/fake-session.schema.json
Normal file
11
selfservice/flow/registration/stub/fake-session.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
selfservice/flow/registration/stub/identity.schema.json
Normal file
44
selfservice/flow/registration/stub/identity.schema.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"booly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"should_big_number": {
|
||||
"type": "number",
|
||||
"minimum": 1200
|
||||
},
|
||||
"should_long_string": {
|
||||
"type": "string",
|
||||
"minLength": 25
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
selfservice/flow/registration/stub/login.schema.json
Normal file
17
selfservice/flow/registration/stub/login.schema.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$id": "https://example.com/registration-tos.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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tos": {
|
||||
"type": "boolean",
|
||||
"title": "Accept Terms of Service"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone-label": {
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "sms"
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "sms"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
selfservice/flow/registration/stub/registration.schema.json
Normal file
33
selfservice/flow/registration/stub/registration.schema.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "email"
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/flow/registration/stub/updated.schema.json
Normal file
16
selfservice/flow/registration/stub/updated.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
selfservice/flow/registration/test/persistence.go
Normal file
197
selfservice/flow/registration/test/persistence.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/persistence"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow/registration"
|
||||
"github.com/ory/x/sqlcon"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/ory/x/assertx"
|
||||
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestFlowPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
|
||||
var clearids = func(r *registration.Flow) {
|
||||
r.ID = uuid.UUID{}
|
||||
}
|
||||
|
||||
return func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
|
||||
|
||||
t.Run("case=should error when the registration flow does not exist", func(t *testing.T) {
|
||||
_, err := p.GetRegistrationFlow(ctx, x.NewUUID())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
var newFlow = func(t *testing.T) *registration.Flow {
|
||||
var r registration.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
clearids(&r)
|
||||
return &r
|
||||
}
|
||||
|
||||
t.Run("case=should create a new registration flow and properly set IDs", func(t *testing.T) {
|
||||
r := newFlow(t)
|
||||
err := p.CreateRegistrationFlow(ctx, r)
|
||||
require.NoError(t, err, "%#v", err)
|
||||
|
||||
assert.NotEqual(t, uuid.Nil, r.ID)
|
||||
})
|
||||
|
||||
t.Run("case=should create with set ids", func(t *testing.T) {
|
||||
var r registration.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
require.NoError(t, p.CreateRegistrationFlow(ctx, &r))
|
||||
})
|
||||
|
||||
t.Run("case=should create and fetch a registration flow", func(t *testing.T) {
|
||||
expected := newFlow(t)
|
||||
err := p.CreateRegistrationFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.GetRegistrationFlow(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 not cause data loss when updating a request without changes", 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.CreateRegistrationFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected.UI.Action = "/new-action"
|
||||
expected.UI.Nodes.Append(
|
||||
node.NewInputField("zab", nil, node.DefaultGroup, "zab", node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Pattern = "zab"
|
||||
})))
|
||||
|
||||
expected.RequestURL = "/new-request-url"
|
||||
require.NoError(t, p.UpdateRegistrationFlow(ctx, expected))
|
||||
|
||||
actual, err := p.GetRegistrationFlow(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 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.CreateRegistrationFlow(ctx, r))
|
||||
assert.Equal(t, tc.expect, string(r.InternalContext))
|
||||
|
||||
r.InternalContext = tc.in
|
||||
require.NoError(t, p.UpdateRegistrationFlow(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) {
|
||||
now := time.Now()
|
||||
expected := ®istration.Flow{ID: id, ExpiresAt: now.Add(time.Hour), IssuedAt: now}
|
||||
require.NoError(t, p.CreateRegistrationFlow(ctx, expected))
|
||||
assert.EqualValues(t, id, expected.ID)
|
||||
assert.EqualValues(t, nid, expected.NID)
|
||||
|
||||
actual, err := p.GetRegistrationFlow(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.GetRegistrationFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p.UpdateRegistrationFlow(ctx, expected))
|
||||
|
||||
actual, err := p.GetRegistrationFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, id, actual.ID)
|
||||
assert.EqualValues(t, nid, actual.NID)
|
||||
})
|
||||
|
||||
t.Run("can not update on another network", func(t *testing.T) {
|
||||
expected, err := p.GetRegistrationFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, other := testhelpers.NewNetwork(t, ctx, p)
|
||||
|
||||
expected.RequestURL = "updated"
|
||||
require.Error(t, other.UpdateRegistrationFlow(ctx, expected))
|
||||
|
||||
_, err = other.GetRegistrationFlow(ctx, id)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
actual, err := p.GetRegistrationFlow(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.GetRegistrationFlow(ctx, id)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
20
selfservice/flow/registration/testsetup_test.go
Normal file
20
selfservice/flow/registration/testsetup_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package registration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var returnToServer *httptest.Server
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
returnToServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user