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
900 lines
34 KiB
Go
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)
|
|
})
|
|
}
|