Files
upstream-ory-kratos/selfservice/strategy/passkey/passkey_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

966 lines
39 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"bytes"
"context"
_ "embed"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/configx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/registrationhelpers"
"github.com/ory/kratos/pkg/testhelpers"
"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/x/assertx"
"github.com/ory/x/contextx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/randx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlxx"
)
var (
flows = []string{"spa", "browser", "api"}
//go:embed fixtures/registration/success/browser/response.json
registrationFixtureSuccessResponse []byte
//go:embed fixtures/registration/success/browser/internal_context.json
registrationFixtureSuccessBrowserInternalContext []byte
//go:embed fixtures/registration/success/android/response.json
registrationFixtureSuccessAndroidResponse []byte
//go:embed fixtures/registration/success/android/internal_context.json
registrationFixtureSuccessAndroidInternalContext []byte
//go:embed fixtures/registration/failure/internal_context_missing_user_id.json
registrationFixtureFailureInternalContextMissingUserID []byte
//go:embed fixtures/registration/failure/internal_context_wrong_user_id.json
registrationFixtureFailureInternalContextWrongUserID []byte
)
func flowIsSPA(flow string) bool {
return flow == "spa"
}
func TestRegistration(t *testing.T) {
t.Parallel()
t.Run("AssertCommonErrorCases", func(t *testing.T) {
registrationhelpers.AssertCommonErrorCases(t, flows)
})
t.Run("AssertRegistrationRespectsValidation", func(t *testing.T) {
t.Parallel()
reg := newRegistrationRegistry(t)
registrationhelpers.AssertRegistrationRespectsValidation(t, reg, flows, func(v url.Values) {
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
})
})
t.Run("AssertCSRFFailures", func(t *testing.T) {
t.Parallel()
// Use the shared helper for browser and spa flows (they already provide
// the WebAuthn internal context via the UI flow initialization).
reg := newRegistrationRegistry(t)
registrationhelpers.AssertCSRFFailures(t, reg, []string{"spa", "browser"}, func(v url.Values) {
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
})
// Prepare API flow once and persist the internal context so the
// subsequent header-mangling subtests can reuse it.
fix := newRegistrationFixture(t)
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, fix.publicTS)
interim, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = registrationFixtureSuccessBrowserInternalContext
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(t.Context(), interim))
values := url.Values{
"csrf_token": {"invalid_token"},
"traits.username": {testhelpers.RandomEmail()},
"traits.foobar": {"bar"},
}
values.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
values.Del("method")
// Check that missing CSRF passes for API (registration happens)
actual, res := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
require.EqualValues(t, http.StatusOK, res.StatusCode)
assert.NotEmpty(t, gjson.Get(actual, "identity.id").Raw, "%s", actual)
// Now test the specific CSRF error causes for API flows: adding Cookie and Origin headers
for _, tc := range []struct {
mod func(http.Header)
exp string
expKey string
}{
{
mod: func(h http.Header) { h.Add("Cookie", "name=bar") },
exp: "The HTTP Request Header included the \"Cookie\" key",
expKey: "Cookie",
},
{
mod: func(h http.Header) { h.Add("Origin", "www.bar.com") },
exp: "The HTTP Request Header included the \"Origin\" key",
expKey: "Origin",
},
} {
// Name this test to match the project's existing case naming and mark it as a local variant.
t.Run(fmt.Sprintf("case=should_fail_with_correct_CSRF_error_cause_local/type=api/%s", tc.expKey), func(t *testing.T) {
f2 := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, fix.publicTS)
// attach internal context to the new flow
interim2, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(f2.Id))
require.NoError(t, err)
interim2.InternalContext = registrationFixtureSuccessBrowserInternalContext
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(t.Context(), interim2))
req, err := http.NewRequest("POST", f2.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
tc.mod(req.Header)
res, err := apiClient.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
bodyBytes := ioutilx.MustReadAll(res.Body)
actual := string(bodyBytes)
require.EqualValues(t, http.StatusBadRequest, res.StatusCode)
// Extract the first ui message text (API error payload)
msg := gjson.Get(actual, "ui.messages.0.text").String()
// Ensure the API error mentions the header key we added (Cookie/Origin)
assert.Contains(t, msg, tc.expKey, "actual payload: %s", actual)
})
}
})
t.Run("AssertSchemaDoesNotExist", func(t *testing.T) {
t.Parallel()
reg := newRegistrationRegistry(t)
registrationhelpers.AssertSchemaDoesNotExist(t, reg, flows, func(v url.Values) {
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
})
})
t.Run("case=passkey button does not exist when passwordless is disabled", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t, configx.WithValue(config.ViperKeyPasskeyEnabled, false))
uiFlows := []string{"spa", "browser"}
for _, flowType := range uiFlows {
flowType := flowType
t.Run(flowType, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
flo := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, flowIsSPA(flowType), false, false)
testhelpers.SnapshotTExcept(t, flo.Ui.Nodes, []string{
"0.attributes.value",
})
})
}
})
t.Run("case=passkey button exists", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
uiFlows := []string{"spa", "browser"}
for _, flowType := range uiFlows {
flowType := flowType
t.Run(flowType, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, flowIsSPA(flowType), false, false)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"3.attributes.src",
"3.attributes.nonce",
"6.attributes.value",
})
})
}
})
t.Run("case=should return an error because not passing validation", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual := registrationhelpers.ExpectValidationError(t.Context(), t, fix.publicTS, fix.conf, f, values)
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
})
}
})
t.Run("case=should reject invalid transient payload", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bar")
v.Set("transient_payload", "42")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual := registrationhelpers.ExpectValidationError(t.Context(), t, fix.publicTS, fix.conf, f, values)
assert.NotEmptyf(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Containsf(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Equalf(t, "bar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", actual)
assert.Equalf(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Equalf(t, int64(4000026), gjson.Get(actual, "ui.nodes.#(attributes.name==transient_payload).messages.0.id").Int(), "%s", actual)
})
}
})
t.Run("case=should return an error because passkey response is invalid", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, "invalid")
v.Set("method", "passkey")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual, _, _ := fix.submitPasskeyRegistration(t.Context(), t, f, testhelpers.NewClientWithCookies(t), values)
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), node.PasskeyRegister, "csrf_token", "traits.username", "traits.foobar")
assert.Equal(t, "bazbar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", actual)
assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.messages.0").String(), `Unable to parse WebAuthn response: Parse error for Registration`, "%s", actual)
})
}
})
t.Run("case=should return an error because internal context is invalid", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
for _, tc := range []struct {
name string
internalContext string
}{{
name: "invalid json",
internalContext: "invalid",
}, {
name: "missing user ID",
internalContext: string(registrationFixtureFailureInternalContextMissingUserID),
}, {
name: "wrong user ID",
internalContext: string(registrationFixtureFailureInternalContextWrongUserID),
}} {
tc := tc
t.Run("context="+tc.name, func(t *testing.T) {
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual, _, _ := fix.submitPasskeyRegistration(t.Context(), t, f, testhelpers.NewClientWithCookies(t), values,
withInternalContext(sqlxx.JSONRawMessage(tc.internalContext)))
if flowIsSPA(f) || f == "api" {
assert.Equal(t, "Internal Server Error", gjson.Get(actual, "error.status").String(), "%s", actual)
} else {
// Browser uses top-level status
assert.Equal(t, "Internal Server Error", gjson.Get(actual, "status").String(), "%s", actual)
}
})
}
})
}
})
t.Run("case=should fail to create identity if schema is missing the identifier", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/noid.schema.json")
email := testhelpers.RandomEmail()
uiFlows := []string{"spa", "browser"}
for _, f := range uiFlows {
t.Run("type="+f, func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
isSPA := f == "spa"
regFlow := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, isSPA, false, false)
// fill out traits and click on "sign up with passkey"
urlValues := testhelpers.SDKFormFieldsToURLValues(regFlow.Ui.Nodes)
urlValues.Set("traits.email", email)
urlValues.Set("method", "passkey")
actual, _ := testhelpers.RegistrationMakeRequest(t, false, isSPA, regFlow, client, urlValues.Encode())
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.email")
assert.Equal(t, text.NewErrorValidationRegistrationNoStrategyFound().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
t.Run("case=registration flow with multiple flagged traits returns all candidate field names", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration-multi-identifier.schema.json")
uiFlows := []string{"spa", "browser"}
for _, f := range uiFlows {
t.Run("type="+f, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
isSPA := f == "spa"
// Before the multi-trait fix this would 500 inside InitializeRegistrationFlowViaBrowser
// with "no identifier found".
regFlow := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, isSPA, false, false)
// Find the passkey_create_data hidden input node and extract its value.
var createDataValue string
for _, n := range regFlow.Ui.Nodes {
attrs := n.Attributes.UiNodeInputAttributes
if attrs == nil {
continue
}
if attrs.Name != node.PasskeyCreateData {
continue
}
if v, ok := attrs.Value.(string); ok {
createDataValue = v
}
break
}
require.NotEmpty(t, createDataValue, "passkey_create_data node must be present and have a non-empty value")
// Both flagged trait paths must appear in displayNameFieldNames.
fieldNames := gjson.Get(createDataValue, "displayNameFieldNames")
require.True(t, fieldNames.IsArray(), "displayNameFieldNames must be a JSON array, got: %s", createDataValue)
names := make([]string, 0, len(fieldNames.Array()))
for _, r := range fieldNames.Array() {
names = append(names, r.String())
}
assert.True(t, slices.Contains(names, "traits.email"),
"traits.email must be in displayNameFieldNames, got: %v", names)
assert.True(t, slices.Contains(names, "traits.phone"),
"traits.phone must be in displayNameFieldNames, got: %v", names)
// Backward compatibility: the singular field stays populated with
// the alphabetically-first candidate so custom UIs that only read
// it continue to work.
assert.Equal(t, "traits.email", gjson.Get(createDataValue, "displayNameFieldName").String(),
"displayNameFieldName must be the first sorted candidate")
})
}
})
getPrefix := func(f string) (prefix string) {
if f == "spa" || f == "api" {
prefix = "session."
}
return
}
t.Run("successful registration", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
t.Cleanup(fix.disableSessionAfterRegistration)
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=should create the identity but not a session", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
email := f + "-" + testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirNoSessionTS.URL+"/registration-return-ts", values(email), withUserID(userID))
if f == "spa" || f == "api" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, "aal1", i.InternalAvailableAAL.String)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
t.Run("case=should accept valid transient payload", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirNoSessionTS.URL+"/registration-return-ts", func(v url.Values) {
values(email)(v)
v.Set("transient_payload.stuff", "42")
}, withUserID(userID))
if f == "spa" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
if f == "api" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
if f == "spa" {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), fix.redirNoSessionTS.URL+"/registration-return-ts", "%s", actual)
} else {
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
}
})
}
})
t.Run("case=should create the identity and a session and use the correct schema", func(t *testing.T) {
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "advanced-user")
fix.conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "does-not-exist", URL: "file://./stub/profile.schema.json"},
{ID: "advanced-user", URL: "file://./stub/registration.schema.json"},
})
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
t.Run("case=not able to create the same account twice", func(t *testing.T) {
fix.enableSessionAfterRegistration()
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration.schema.json")
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
assert.True(t, gjson.Get(actual, getPrefix(f)+"active").Bool(), "%s", actual)
actual, _, _ = fix.makeRegistration(t, f, values(email))
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username")
assert.Equal(t,
"You tried signing in with "+email+" which is already in use by another account. You can sign in using your passkey.",
gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
t.Run("case=reset previous form errors", func(t *testing.T) {
fix.enableSessionAfterRegistration()
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration.schema.json")
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
actual, _, _ := fix.makeRegistration(t, f, func(v url.Values) {
v.Del("traits.username")
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
})
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages.0").String(), `Property username is missing`, "%s", actual)
actual, _, _ = fix.makeRegistration(t, f, func(v url.Values) {
v.Set("traits.username", email)
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
})
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array())
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
})
}
})
t.Run("case=should create the identity when using android", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
prevRPID := fix.conf.GetProvider(t.Context()).String(config.ViperKeyPasskeyRPID)
prevOrigins := fix.conf.GetProvider(t.Context()).String(config.ViperKeyPasskeyRPOrigins)
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPID, "www.troweprice.com")
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPOrigins, []string{"android:apk-key-hash:S2RfNYgJmQiKgd6-sdbjW7phcL_OTP4vGE8L51Q2GB0"})
t.Cleanup(func() {
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPID, prevRPID)
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPOrigins, prevOrigins)
})
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
email := f + "-" + testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
expectReturnTo := fix.redirNoSessionTS.URL + "/registration-return-ts"
actual, res, _ := fix.submitPasskeyAndroidRegistration(t, f, testhelpers.NewClientWithCookies(t), func(v url.Values) {
values(email)(v)
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessAndroidResponse))
}, withUserID(userID))
if f == "spa" || f == "api" {
if f == "spa" {
expectReturnTo = fix.publicTS.URL
}
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
if f != "api" {
assert.Containsf(t, res.Request.URL.String(), expectReturnTo, "%+v\n\t%s", res.Request, assertx.PrettifyJSONPayload(t, actual))
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, "aal1", i.InternalAvailableAAL.String)
assert.Equalf(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
t.Run("case=multi-schema registration", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
invalidValues := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "b")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
fix.conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "does-not-exist")
fix.conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "does-not-exist", URL: "file://./stub/profile.schema.json"},
{ID: "advanced-user", URL: "file://./stub/registration.schema.json", SelfserviceSelectable: true},
})
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
t.Run("should create the identity and a session and use the correct schema", func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID), withInitFlowWithOption([]testhelpers.InitFlowWithOption{testhelpers.InitFlowWithIdentitySchema("advanced-user")}))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
assert.Equal(t, "advanced-user", i.SchemaID, "%s", actual)
})
t.Run("registration should fail with invalid form data using the correct schema", func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual, res := fix.makeUnsuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", invalidValues(email), withUserID(userID), withInitFlowWithOption([]testhelpers.InitFlowWithOption{testhelpers.InitFlowWithIdentitySchema("advanced-user")}))
if f == "browser" {
assert.Equal(t, http.StatusOK, res.StatusCode, "%s", actual)
} else {
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual)
}
assert.Equal(t, int64(4000003), gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.id").Int(), "%s", actual)
assert.Equal(t, "length must be \u003e= 2, but got 1", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.text").String(), "%s", actual)
})
})
}
})
t.Run("case=profile_first registration flow", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationFlowStyle, "profile_first")
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=successful registration", func(t *testing.T) {
for _, f := range []string{"browser", "spa"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-profile-first-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
t.Run("case=unified registration flow", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationFlowStyle, "unified")
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=successful registration", func(t *testing.T) {
for _, f := range []string{"browser", "spa"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-unified-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
}
func TestPopulateRegistrationMethod(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/registration.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPDisplayName, "localhost")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPID, "localhost")
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePasskey)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f node.Nodes, except ...snapshotx.Opt) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f, append(except, 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
}
newFlowWithType := func(ctx context.Context, t *testing.T, flowType flow.Type) (*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, flowType)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("type=api", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeAPI)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(ctx, t)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("type=api", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeAPI)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
})
t.Run("method=idempotency", func(t *testing.T) {
r, f := newFlow(ctx, 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, snapshotx.ExceptPaths("1.attributes.value"))
})
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, snapshotx.ExceptPaths("1.attributes.value"))
})
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, snapshotx.ExceptPaths("1.attributes.value"))
})
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, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("case=evaluate", func(t *testing.T) {
assertx.EqualAsJSON(t, snapshots[0], snapshots[2])
assertx.EqualAsJSONExcept(t, snapshots[1], snapshots[3], []string{"3.attributes.nonce"})
})
})
}
// TestPopulateRegistrationMethodWithoutDisplayNameTrait is a regression test:
// when the passkey method is enabled but the identity schema flags no trait as
// a passkey display name or WebAuthn identifier and offers no untitled
// fallback trait, the passkey form hydrators must skip their UI nodes instead
// of failing the whole registration flow.
func TestPopulateRegistrationMethodWithoutDisplayNameTrait(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/registration-no-passkey-titled.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPDisplayName, "localhost")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPID, "localhost")
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePasskey)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
newFlow := func(t *testing.T, flowType flow.Type) (*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, flowType)
require.NoError(t, err)
f.UI.Nodes = make(node.Nodes, 0)
return r, f
}
assertNoPasskeyNodes := func(t *testing.T, nodes node.Nodes) {
t.Helper()
for _, n := range nodes {
assert.NotEqual(t, node.PasskeyGroup, n.Group, "unexpected passkey node: %+v", n)
assert.NotEqual(t, node.WebAuthnScript, n.ID(), "unexpected WebAuthn script node: %+v", n)
}
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
for _, ft := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(ft), func(t *testing.T) {
r, f := newFlow(t, ft)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
}
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
r, f := newFlow(t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
}
// TestRegistrationWithoutDisplayNameTrait initializes registration flows
// end-to-end with the passkey method enabled and an identity schema that has
// no display-name candidate trait. Flow creation must succeed and offer the
// remaining methods; only the passkey UI nodes are dropped.
func TestRegistrationWithoutDisplayNameTrait(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t, configx.WithValues(
testhelpers.DefaultIdentitySchemaConfig("file://./stub/registration-no-passkey-titled.schema.json")))
assertFlow := func(t *testing.T, f *kratos.RegistrationFlow) {
t.Helper()
var groups []string
for _, n := range f.Ui.Nodes {
groups = append(groups, n.Group)
assert.NotEqual(t, "passkey", n.Group, "unexpected passkey node: %+v", n)
}
assert.Contains(t, groups, "password")
}
t.Run("type=browser", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, false, false, false)
assertFlow(t, f)
})
t.Run("type=spa", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, true, false, false)
assertFlow(t, f)
})
t.Run("type=api", func(t *testing.T) {
f := testhelpers.InitializeRegistrationFlowViaAPI(t, testhelpers.NewDebugClient(t), fix.publicTS)
assertFlow(t, f)
})
}