Files
upstream-ory-kratos/selfservice/strategy/code/strategy_registration_test.go
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

900 lines
34 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package code_test
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/x/configx"
"github.com/ory/x/sqlxx"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
oryClient "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/strategy/code"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/pop/v6"
"github.com/ory/x/assertx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlcon"
)
type state struct {
flowID string
client *http.Client
email string
testServer *httptest.Server
resultIdentity *identity.Identity
body string
}
func TestRegistrationCodeStrategyDisabled(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeCodeAuth, false)),
configx.WithValues(map[string]any{
fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth): false,
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
}),
)
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
client := testhelpers.NewClientWithCookies(t)
resp, err := client.Get(public.URL + registration.RouteInitBrowserFlow)
require.NoError(t, err)
require.EqualValues(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Falsef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body)
// attempt to still submit the code form even though it doesn't exist
payload := strings.NewReader(url.Values{
"csrf_token": {gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()},
"method": {"code"},
"traits.email": {testhelpers.RandomEmail()},
}.Encode())
req, err := http.NewRequestWithContext(t.Context(), "POST", public.URL+registration.RouteSubmitFlow+"?flow="+gjson.GetBytes(body, "id").String(), payload)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err = client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "This endpoint was disabled by system administrator. Please check your url or contact the system administrator to enable it.", gjson.GetBytes(body, "error.reason").String())
}
func TestRegistrationCodeStrategy(t *testing.T) {
t.Parallel()
type ApiType string
const (
ApiTypeBrowser ApiType = "browser"
ApiTypeSPA ApiType = "spa"
ApiTypeNative ApiType = "api"
)
setup := func(ctx context.Context, t *testing.T, cfgOpts ...configx.OptionModifier) (*config.Config, *driver.RegistryDefault, *httptest.Server) {
conf, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeCodeAuth, true)),
configx.WithValues(map[string]any{
fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth): true,
config.ViperKeySelfServiceRegistrationAfter + ".code.hooks": []map[string]any{
{"hook": "session"},
},
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
}),
}, cfgOpts...)...)
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
return conf, reg, public
}
createRegistrationFlowInternal := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, identitySchema string) *state {
t.Helper()
var client *http.Client
if apiType == ApiTypeNative {
client = &http.Client{}
} else {
client = testhelpers.NewClientWithCookies(t)
}
client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper
var clientInit *oryClient.RegistrationFlow
if apiType == ApiTypeNative {
clientInit = testhelpers.InitializeRegistrationFlowViaAPICtx(ctx, t, client, public, testhelpers.InitFlowWithIdentitySchema(identitySchema))
} else {
clientInit = testhelpers.InitializeRegistrationFlowViaBrowserCtx(ctx, t, client, public, apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithIdentitySchema(identitySchema))
}
body, err := json.Marshal(clientInit)
require.NoError(t, err)
csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
if apiType == ApiTypeNative {
require.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body)
} else {
require.NotEmpty(t, csrfToken)
}
require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body)
require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body)
return &state{
client: client,
flowID: clientInit.GetId(),
testServer: public,
}
}
createRegistrationFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType) *state {
return createRegistrationFlowInternal(ctx, t, public, apiType, "")
}
createRegistrationFlowWithIdentity := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, identitySchema string) *state {
return createRegistrationFlowInternal(ctx, t, public, apiType, identitySchema)
}
type onSubmitAssertion func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response)
registerNewUser := func(ctx context.Context, t *testing.T, s *state, apiType ApiType, submitAssertion onSubmitAssertion) *state {
t.Helper()
if s.email == "" {
s.email = testhelpers.RandomEmail()
}
rf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendAPI.GetRegistrationFlow(context.Background()).Id(s.flowID).Execute()
require.NoError(t, err)
require.EqualValues(t, http.StatusOK, resp.StatusCode)
values := testhelpers.SDKFormFieldsToURLValues(rf.Ui.Nodes)
values.Set("traits.email", s.email)
values.Set("traits.tos", "1")
values.Set("method", "code")
body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values))
s.body = body
if submitAssertion != nil {
submitAssertion(ctx, t, s, body, resp)
return s
}
t.Logf("%v", body)
if apiType == ApiTypeBrowser {
require.EqualValues(t, http.StatusOK, resp.StatusCode)
} else {
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
}
csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
if apiType == ApiTypeNative {
assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body)
} else {
assert.NotEmptyf(t, csrfToken, "%s", body)
}
require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String())
return s
}
submitOTP := func(ctx context.Context, t *testing.T, reg *driver.RegistryDefault, s *state, vals func(v *url.Values), apiType ApiType, submitAssertion onSubmitAssertion) *state {
t.Helper()
rf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendAPI.GetRegistrationFlow(context.Background()).Id(s.flowID).Execute()
require.NoError(t, err)
require.EqualValues(t, http.StatusOK, resp.StatusCode)
values := testhelpers.SDKFormFieldsToURLValues(rf.Ui.Nodes)
// the sdk to values always adds resend which isn't what we always need here.
// so we delete it here.
// the custom vals func can add it again if needed.
values.Del("resend")
values.Set("traits.email", s.email)
values.Set("traits.tos", "1")
vals(&values)
body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values))
s.body = body
if submitAssertion != nil {
submitAssertion(ctx, t, s, body, resp)
return s
}
require.Equal(t, http.StatusOK, resp.StatusCode, body)
verifiableAddress, err := reg.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, s.email)
require.NoError(t, err)
require.Equal(t, strings.ToLower(s.email), verifiableAddress.Value)
id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, verifiableAddress.IdentityID)
require.NoError(t, err)
require.NotNil(t, id.ID)
_, ok := id.GetCredentials(identity.CredentialsTypeCodeAuth)
require.True(t, ok)
s.resultIdentity = id
return s
}
t.Run("test=different flows on the same configurations", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg, public := setup(ctx, t)
for _, tc := range []struct {
d string
apiType ApiType
}{
{
d: "SPA client",
apiType: ApiTypeSPA,
},
{
d: "Browser client",
apiType: ApiTypeBrowser,
},
{
d: "Native client",
apiType: ApiTypeNative,
},
} {
t.Run("flow="+tc.d, func(t *testing.T) {
t.Run("case=should be able to register with code identity credentials", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
state := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
state = registerNewUser(ctx, t, state, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// 3. Submit OTP
state = submitOTP(ctx, t, reg, state, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, nil)
switch tc.apiType {
case ApiTypeSPA:
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body)
assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body)
case ApiTypeBrowser:
assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body)
case ApiTypeNative:
assert.NotContains(t, gjson.Get(state.body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", state.body)
}
})
t.Run("case=should normalize email address on sign up", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
state := createRegistrationFlow(ctx, t, public, tc.apiType)
sourceMail := testhelpers.RandomEmail()
state.email = strings.ToUpper(sourceMail)
assert.NotEqual(t, sourceMail, state.email)
// 2. Submit Identifier (email)
state = registerNewUser(ctx, t, state, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, sourceMail, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// 3. Submit OTP
state = submitOTP(ctx, t, reg, state, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, nil)
creds, ok := state.resultIdentity.GetCredentials(identity.CredentialsTypeCodeAuth)
require.True(t, ok)
require.Len(t, creds.Identifiers, 1)
assert.Equal(t, sourceMail, creds.Identifiers[0])
})
t.Run("case=should be able to resend the code", func(t *testing.T) {
ctx := context.Background()
s := createRegistrationFlow(ctx, t, public, tc.apiType)
s = registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.EqualValues(t, http.StatusOK, resp.StatusCode)
} else {
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
}
csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
if tc.apiType == ApiTypeNative {
require.Empty(t, csrfToken, "expected the csrf_token to be empty but got %s", body)
} else {
require.NotEmptyf(t, csrfToken, "expected the csrf_token to exist but got %s", body)
}
require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String())
attr := gjson.Get(body, "ui.nodes.#(attributes.name==method)#").String()
require.NotEmpty(t, attr)
val := gjson.Get(attr, "#(attributes.type==hidden).attributes.value").String()
require.Equal(t, "code", val, body)
})
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// resend code
s = submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("resend", "code")
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode)
} else {
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
}
csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
if tc.apiType == ApiTypeNative {
assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body)
} else {
require.NotEmptyf(t, csrfToken, "expected to find the csrf_token but got %s", body)
}
require.Containsf(t, gjson.Get(body, "ui.messages").String(), "A code has been sent to the address(es) you provided", "%s", body)
})
// get the new code from email
message = testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode2 := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode2)
require.NotEqual(t, registrationCode, registrationCode2)
// try submit old code
s = submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages").String(), "The registration code is invalid or has already been used. Please try again")
})
submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", registrationCode2)
}, tc.apiType, nil)
})
t.Run("case=swapping out traits should not be possible on code submit", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
s := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
s = registerNewUser(ctx, t, s, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
s.email = "not-" + s.email // swap out email
// 3. Submit OTP
submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.")
})
})
t.Run("case=swapping out traits that aren't strings should not be possible on code submit", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
s := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
s = registerNewUser(ctx, t, s, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// 3. Submit OTP
submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", registrationCode)
v.Set("traits.tos", "0")
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.")
})
})
t.Run("case=code should not be able to use more than 5 times", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
s := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
s = registerNewUser(ctx, t, s, tc.apiType, nil)
err := reg.Persister().Transaction(ctx, func(ctx context.Context, connection *pop.Connection) error {
count, err := connection.RawQuery(fmt.Sprintf("SELECT * FROM %s WHERE selfservice_registration_flow_id = ?", new(code.RegistrationCode).TableName(ctx)), uuid.FromStringOrNil(s.flowID)).Count(new(code.RegistrationCode))
require.NoError(t, err)
require.Equal(t, 1, count)
return nil
})
require.NoError(t, err)
for i := 0; i < 5; i++ {
s = submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", "111111")
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The registration code is invalid or has already been used")
})
}
s = submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", "111111")
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The request was submitted too often.")
})
})
})
}
})
t.Run("test=cases with different configs", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg, public := setup(ctx, t)
for _, tc := range []struct {
d string
apiType ApiType
}{
{
d: "SPA client",
apiType: ApiTypeSPA,
},
{
d: "Browser client",
apiType: ApiTypeBrowser,
},
{
d: "Native client",
apiType: ApiTypeNative,
},
} {
t.Run("test="+tc.d, func(t *testing.T) {
t.Run("case=should fail when schema does not contain the `code` extension", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/no-code.schema.json")
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json")
})
// 1. Initiate flow
s := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
// we expect a redirect to the registration page with the flow id
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, conf.SelfServiceFlowRegistrationUI(ctx).Path, resp.Request.URL.Path)
rf, resp, err := testhelpers.NewSDKCustomClient(public, s.client).FrontendAPI.GetRegistrationFlow(ctx).Id(resp.Request.URL.Query().Get("flow")).Execute()
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := json.Marshal(rf)
require.NoError(t, err)
require.Contains(t, gjson.GetBytes(body, "ui.messages").String(), "Could not find any login identifiers")
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%v", body)
require.Contains(t, gjson.Get(body, "ui.messages").String(), "Could not find any login identifiers")
}
})
})
t.Run("case=should have verifiable address even if after session hook is disabled", func(t *testing.T) {
// disable the after session hook
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]any{})
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]any{
{"hook": "session"},
})
})
// 1. Initiate flow
state := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
state = registerNewUser(ctx, t, state, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// 3. Submit OTP
submitOTP(ctx, t, reg, state, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, nil)
})
t.Run("case=code should expire", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.config.lifespan", "10ns")
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.config.lifespan", "1h")
})
// 1. Initiate flow
s := createRegistrationFlow(ctx, t, public, tc.apiType)
// 2. Submit Identifier (email)
s = registerNewUser(ctx, t, s, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
submitOTP(ctx, t, reg, s, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
} else {
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
}
require.Contains(t, gjson.Get(body, "ui.messages").String(), "The registration code is invalid or has already been used. Please try again", "%s", body)
})
})
})
}
})
t.Run("test=multi-schema select", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg, public := setup(ctx, t, configx.WithValues(map[string]any{
config.ViperKeyIdentitySchemas: config.Schemas{
{ID: "code", URL: "file://./stub/code.identity.schema.json", SelfserviceSelectable: true},
{ID: "no-code", URL: "file://stub/no-code.schema.json", SelfserviceSelectable: true},
},
config.ViperKeyDefaultIdentitySchemaID: "no-code",
}))
for _, tc := range []struct {
d string
apiType ApiType
}{
{
d: "SPA client",
apiType: ApiTypeSPA,
},
{
d: "Browser client",
apiType: ApiTypeBrowser,
},
{
d: "Native client",
apiType: ApiTypeNative,
},
} {
t.Run("flow="+tc.d, func(t *testing.T) {
t.Run("case=should be able to register with code identity credentials", func(t *testing.T) {
ctx := context.Background()
// 1. Initiate flow
state := createRegistrationFlowWithIdentity(ctx, t, public, tc.apiType, "code")
state.email = testhelpers.RandomEmail()
// 2. Submit Identifier (email)
state = registerNewUser(ctx, t, state, tc.apiType, nil)
message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code")
assert.Contains(t, message.Body, "Complete your account registration with the following code")
registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1)
assert.NotEmpty(t, registrationCode)
// 3. Submit OTP
state = submitOTP(ctx, t, reg, state, func(v *url.Values) {
v.Set("code", registrationCode)
}, tc.apiType, nil)
switch tc.apiType {
case ApiTypeSPA:
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body)
assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body)
case ApiTypeBrowser:
assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body)
case ApiTypeNative:
assert.NotContains(t, gjson.Get(state.body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", state.body)
}
identity, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypeCodeAuth, state.email)
require.NoError(t, err, sqlcon.ErrNoRows())
assert.NotEmpty(t, identity.ID, "%s", identity.ID)
assert.Equal(t, state.email, gjson.Get(identity.Traits.String(), "email").String(), "%s", identity.Traits.String())
assert.Equal(t, "code", identity.SchemaID, "%s", identity.SchemaID)
})
t.Run("case=registration should fail with invalid form data", func(t *testing.T) {
// 1. Initiate flow
s := createRegistrationFlowWithIdentity(t.Context(), t, public, tc.apiType, "code")
s.email = "invalidemail"
// 2. Submit Identifier (email)
registerNewUser(t.Context(), t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) {
if tc.apiType == ApiTypeBrowser {
require.EqualValues(t, http.StatusOK, resp.StatusCode)
} else {
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
}
require.Equal(t, int64(4000040), gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).messages.0.id").Int(), "%s", body)
require.Equal(t, "Enter a valid email address", gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).messages.0.text").String(), "%s", body)
})
})
})
}
})
}
func TestPopulateRegistrationMethod(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/code.identity.schema.json")),
configx.WithValue(fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth), true),
)
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypeCodeAuth)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f node.Nodes) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f, snapshotx.ExceptNestedKeys("nonce", "src"))
}
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) {
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := registration.NewFlow(reg, r, flow.TypeBrowser)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes)
})
t.Run("method=idempotency", func(t *testing.T) {
r, f := newFlow(t.Context(), t)
var snapshots []node.Nodes
t.Run("case=1", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=2", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=3", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=4", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes)
})
t.Run("case=evaluate", func(t *testing.T) {
assertx.EqualAsJSON(t, snapshots[0], snapshots[2])
assertx.EqualAsJSON(t, snapshots[1], snapshots[3])
})
})
}
func TestCodeRegistrationWithLoginChallenge(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth), map[string]any{
"enabled": true,
"passwordless_enabled": true,
}),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")),
)
reg.SetHydra(hydra.NewFake())
reg.WithCSRFTokenGenerator(nosurfx.FakeCSRFTokenGenerator)
s := code.NewStrategy(reg)
loginChallenge := hydra.FakeValidLoginChallenge
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) {
t.Helper()
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
f, err := registration.NewFlow(reg, r, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(ctx, f))
return r, f
}
t.Run("case=fetches login challenge on code input state", func(t *testing.T) {
_, f := newFlow(t.Context(), t)
f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge)
i := identity.NewIdentity(f.IdentitySchema.ID(t.Context(), reg.Config()))
email := testhelpers.RandomEmail()
body := gjson.Parse(`{
"method": "code",
"traits.email": "` + email + `",
"csrf_token": "` + f.CSRFToken + `"
}`)
r := httptest.NewRequest("POST", "/self-service/registration/browser", strings.NewReader(body.Raw))
r.Header.Add("Content-Type", "application/json")
err := s.Register(httptest.NewRecorder(), r, f, i)
require.ErrorIs(t, err, flow.ErrCompletedByStrategy)
require.NotNil(t, f.HydraLoginRequest)
messages, err := reg.CourierPersister().NextMessages(t.Context(), 10)
require.NoError(t, err)
require.Len(t, messages, 1)
assert.Equal(t, loginChallenge, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.challenge").String())
assert.Equal(t, hydra.FakeClientID, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.client_id").String())
assert.Equal(t, hydra.FakeClientName, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.client_name").String())
assert.Equal(t, hydra.FakeClientLogoURI, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.logo_uri").String())
})
t.Run("case=returns error if login challenge is invalid", func(t *testing.T) {
_, f := newFlow(t.Context(), t)
f.OAuth2LoginChallenge = sqlxx.NullString(hydra.FakeInvalidLoginChallenge)
i := identity.NewIdentity(f.IdentitySchema.ID(t.Context(), reg.Config()))
email := testhelpers.RandomEmail()
body := gjson.Parse(`{
"method": "code",
"traits.email": "` + email + `",
"csrf_token": "` + f.CSRFToken + `"
}`)
r := httptest.NewRequest("POST", "/self-service/registration/browser", strings.NewReader(body.Raw))
r.Header.Add("Content-Type", "application/json")
err := s.Register(httptest.NewRecorder(), r, f, i)
require.ErrorIs(t, err, herodot.ErrBadRequest())
require.Nil(t, f.HydraLoginRequest)
// Pin that the invalid login challenge aborts before any one-time code is queued.
_, err = reg.CourierPersister().NextMessages(t.Context(), 1)
require.ErrorIs(t, err, courier.ErrQueueEmpty)
})
}