// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package webauthn_test import ( "context" _ "embed" "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "testing" "time" "github.com/ory/kratos/x/nosurfx" "github.com/ory/x/configx" "github.com/ory/kratos/selfservice/flow" "github.com/ory/x/snapshotx" "github.com/ory/kratos/selfservice/strategy/webauthn" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" kratos "github.com/ory/kratos/pkg/httpclient" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/stretchr/testify/require" "github.com/ory/x/assertx" "github.com/ory/x/sqlxx" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/x" ) //go:embed fixtures/settings/success/identity.json var settingsFixtureSuccessIdentity []byte //go:embed fixtures/settings/success/response.json var settingsFixtureSuccessResponse []byte //go:embed fixtures/settings/success/internal_context.json var settingsFixtureSuccessInternalContext []byte const registerDisplayNameGJSONQuery = "ui.nodes.#(attributes.name==" + node.WebAuthnRegisterDisplayName + ")" func createIdentityWithoutWebAuthn(t *testing.T, reg driver.Registry) *identity.Identity { id := createIdentity(t.Context(), t, reg) delete(id.Credentials, identity.CredentialsTypeWebAuthn) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id)) return id } func createIdentityAndReturnIdentifier(ctx context.Context, t *testing.T, reg driver.Registry, conf []byte) (*identity.Identity, string) { identifier := x.NewUUID().String() + "@ory.sh" password := x.NewUUID().String() p, err := reg.Hasher(ctx).Generate(ctx, []byte(password)) require.NoError(t, err) i := &identity.Identity{ SchemaID: "default", NID: uuid.Must(uuid.NewV4()), Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), VerifiableAddresses: []identity.VerifiableAddress{ { Value: identifier, Verified: false, CreatedAt: time.Now(), }, }, } if conf == nil { conf = []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`) } require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) i.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, Identifiers: []string{identifier}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`), }, identity.CredentialsTypeWebAuthn: { Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{identifier}, Config: conf, }, } require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(ctx, i)) return i, identifier } func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry) *identity.Identity { id, _ := createIdentityAndReturnIdentifier(ctx, t, reg, nil) return id } var enabledWebauthn = func() configx.OptionModifier { webauthn := config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypeWebAuthn) return configx.WithValues(map[string]any{ webauthn + ".enabled": true, webauthn + ".config.rp.display_name": "Ory Corp", webauthn + ".config.rp.id": "localhost", webauthn + ".config.rp.origin": "http://localhost:4455", }) }() func ensureReplacement(t *testing.T, index string, ui kratos.UiContainer, expected string) { actual, err := json.Marshal(ui.Nodes) require.NoError(t, err) assert.Contains(t, gjson.GetBytes(actual, index+".attributes.onclick").String(), expected, "ensure that the replacement works") } func TestCompleteSettings(t *testing.T) { conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{ config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": false, config.ViperKeySelfServiceStrategyConfig + ".profile.enabled": false, config.ViperKeySelfServiceSettingsRequiredAAL: "aal1", config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: "1m", }), enabledWebauthn, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/settings.schema.json")), ) publicTS, _ := testhelpers.NewKratosServer(t, reg) errTS := testhelpers.NewErrorTestServer(t, reg) uiTS := testhelpers.NewSettingsUIFlowEchoServer(t, reg) _ = testhelpers.NewRedirSessionEchoTS(t, reg) loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg) t.Run("case=a device is shown which can be unlinked", func(t *testing.T) { id := createIdentity(t.Context(), t, reg) apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", "5.attributes.onclick", "5.attributes.value", "6.attributes.src", "6.attributes.nonce", }) ensureReplacement(t, "5", f.Ui, "Ory Corp") }) t.Run("case=one activation element is shown", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id)) apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", "3.attributes.onload", "3.attributes.onclick", "3.attributes.value", "4.attributes.src", "4.attributes.nonce", }) ensureReplacement(t, "3", f.Ui, "Ory Corp") }) t.Run("case=registration options exclude already registered credentials", func(t *testing.T) { id := createIdentity(t.Context(), t, reg) apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) nodes, err := json.Marshal(f.Ui.Nodes) require.NoError(t, err) options := gjson.GetBytes(nodes, `#(attributes.name=="webauthn_register_trigger").attributes.value`).String() exclude := gjson.Get(options, "publicKey.excludeCredentials").Array() require.Len(t, exclude, 2, options) assert.Equal(t, "public-key", exclude[0].Get("type").String()) assert.Equal(t, "Zm9vZm9v", exclude[0].Get("id").String()) assert.Equal(t, "YmFyYmFy", exclude[1].Get("id").String()) }) t.Run("case=registration options have no exclusions without credentials", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) nodes, err := json.Marshal(f.Ui.Nodes) require.NoError(t, err) options := gjson.GetBytes(nodes, `#(attributes.name=="webauthn_register_trigger").attributes.value`).String() require.NotEmpty(t, options) assert.False(t, gjson.Get(options, "publicKey.excludeCredentials").Exists(), options) }) t.Run("case=webauthn only works for browsers", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id)) apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) assert.Empty(t, f.Ui.Nodes) }) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) payload := testhelpers.EncodeFormAsJSON(t, true, values) return testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, payload) } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) return testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values)) } t.Run("case=fails with api submit because only browsers are supported", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) body, res := doAPIFlow(t, func(v url.Values) { v.Set(node.WebAuthnRegister, "{}") }, id) assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) assert.Equal(t, text.NewErrorValidationSettingsNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body) }) t.Run("case=fails with browser submit if name is missing", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentityWithoutWebAuthn(t, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRegister, "{}") }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.Contains(t, gjson.Get(body, registerDisplayNameGJSONQuery+".messages.0.text").String(), "Property webauthn_register_displayname is missing.", "%s", body) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=fails with browser submit because csrf token is missing", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentityWithoutWebAuthn(t, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Del("csrf_token") v.Set(node.WebAuthnRegister, "{}") v.Set(node.WebAuthnRegisterDisplayName, "foobar") }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body) } else { assert.Contains(t, res.Request.URL.String(), errTS.URL) assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body) } } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=fails with browser submit register payload is invalid", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentityWithoutWebAuthn(t, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRegister, "{}") v.Set(node.WebAuthnRegisterDisplayName, "foobar") }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "Parse error for Registration", "%s", body) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=requires privileged session for register", func(t *testing.T) { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns") t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m") }) run := func(t *testing.T, spa bool) { id := createIdentityWithoutWebAuthn(t, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRegister, "{}") v.Set(node.WebAuthnRegisterDisplayName, "foobar") }, id) if spa { assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String()) assert.Equal(t, http.StatusForbidden, res.StatusCode) assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(body), []string{"redirect_browser_to"}) } else { assert.Contains(t, res.Request.URL.String(), loginTS.URL+"/login-ts") assertx.EqualAsJSON(t, text.NewInfoLoginReAuth(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) } } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=add a security key", func(t *testing.T) { run := func(t *testing.T, spa bool) { // We load our identity which we will use to replay the webauth session var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) id.NID = uuid.Must(uuid.NewV4()) _ = reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID) browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, &id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) // We inject the session to replay interim, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) interim.InternalContext = settingsFixtureSuccessInternalContext require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(context.Background(), interim)) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) // We use the response replay values.Set(node.WebAuthnRegister, string(settingsFixtureSuccessResponse)) values.Set(node.WebAuthnRegisterDisplayName, "foobar") body, res := testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values)) require.Equal(t, http.StatusOK, res.StatusCode, body) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) cred, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) require.True(t, ok) assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1) actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) // new session data has been generated assert.NotEqual(t, settingsFixtureSuccessInternalContext, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData))) testhelpers.EnsureAAL(t, browserClient, publicTS, "aal2", string(identity.CredentialsTypeWebAuthn)) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=fails to add a security key that is already registered", func(t *testing.T) { // We load our identity which we will use to replay the webauth session var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) id.NID = uuid.Must(uuid.NewV4()) _ = reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID) browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, &id) // Seed the identity with the same credential ID the replayed response will try to register. credID, err := base64.RawURLEncoding.DecodeString(gjson.GetBytes(settingsFixtureSuccessResponse, "rawId").String()) require.NoError(t, err) conf, err := json.Marshal(identity.CredentialsWebAuthnConfig{ Credentials: identity.CredentialsWebAuthn{{ID: credID, DisplayName: "existing key"}}, }) require.NoError(t, err) confidential, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID) require.NoError(t, err) confidential.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ Type: identity.CredentialsTypeWebAuthn, Config: conf, }) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), confidential)) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, true, publicTS) // We inject the session to replay interim, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) interim.InternalContext = settingsFixtureSuccessInternalContext require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(t.Context(), interim)) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) values.Set(node.WebAuthnRegister, string(settingsFixtureSuccessResponse)) values.Set(node.WebAuthnRegisterDisplayName, "foobar") body, res := testhelpers.SettingsMakeRequest(t, false, true, f, browserClient, testhelpers.EncodeFormAsJSON(t, true, values)) assert.Equal(t, http.StatusBadRequest, res.StatusCode, body) assert.Contains(t, body, "already registered", body) actual, err := reg.Persister().GetIdentityConfidential(t.Context(), id.ID) require.NoError(t, err) cred, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) require.True(t, ok) assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1, "the duplicate credential must not be persisted") }) t.Run("case=fails to remove security key if it is passwordless and the last credential available", func(t *testing.T) { conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, true) t.Cleanup(func() { conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, false) }) run := func(t *testing.T, spa bool) { id := createIdentity(t.Context(), t, reg) id.DeleteCredentialsType(identity.CredentialsTypePassword) conf := sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`) id.UpsertCredentialsConfig(identity.CredentialsTypeWebAuthn, conf, 0) require.NoError(t, reg.IdentityManager().Update(t.Context(), id, identity.ManagerAllowWriteProtectedTraits)) body, res := doBrowserFlow(t, spa, func(v url.Values) { // The remove key should be empty snapshotx.SnapshotTExcept(t, v, []string{"csrf_token", "webauthn_register_trigger"}) v.Set(node.WebAuthnRemove, "666f6f666f6f") }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } t.Run("response", func(t *testing.T) { assert.EqualValues(t, flow.StateShowForm, gjson.Get(body, "state").String(), body) snapshotx.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes.#(attributes.name==webauthn_remove)").String()), nil) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) cred, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) assert.True(t, ok) assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1) }) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=possible to remove webauthn credential if it is MFA at all times", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentity(t.Context(), t, reg) id.DeleteCredentialsType(identity.CredentialsTypePassword) id.UpsertCredentialsConfig(identity.CredentialsTypeWebAuthn, sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":false}]}`), 0) require.NoError(t, reg.IdentityManager().Update(t.Context(), id, identity.ManagerAllowWriteProtectedTraits)) body, res := doBrowserFlow(t, spa, func(v url.Values) { // The remove key should be set snapshotx.SnapshotTExcept(t, v, []string{ "csrf_token", "webauthn_register_trigger", }) v.Set(node.WebAuthnRemove, "666f6f666f6f") }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } t.Run("response", func(t *testing.T) { assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) _, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) assert.False(t, ok) }) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=remove all security keys", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentity(t.Context(), t, reg) allCred, ok := id.GetCredentials(identity.CredentialsTypeWebAuthn) assert.True(t, ok) var cc identity.CredentialsWebAuthnConfig require.NoError(t, json.Unmarshal(allCred.Config, &cc)) require.Len(t, cc.Credentials, 2) for _, cred := range cc.Credentials { body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRemove, fmt.Sprintf("%x", cred.ID)) }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) if spa { assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", body) } else { assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) } } actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) _, ok = actual.GetCredentials(identity.CredentialsTypeWebAuthn) assert.False(t, ok) // Check not to remove other credentials with webauthn _, ok = actual.GetCredentials(identity.CredentialsTypePassword) assert.True(t, ok) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=fails with browser submit register payload is invalid", func(t *testing.T) { run := func(t *testing.T, spa bool) { id := createIdentity(t.Context(), t, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRemove, fmt.Sprintf("%x", []byte("foofoo"))) }, id) if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(body, "state").String())) actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) require.NoError(t, err) cred, ok := actual.GetCredentials(identity.CredentialsTypeWebAuthn) assert.True(t, ok) assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1) assert.Equal(t, "bar", gjson.GetBytes(cred.Config, "credentials.0.display_name").String()) } t.Run("type=browser", func(t *testing.T) { run(t, false) }) t.Run("type=spa", func(t *testing.T) { run(t, true) }) }) t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json") for _, f := range []string{"spa", "browser"} { t.Run("type="+f, func(t *testing.T) { isSPA := f == "spa" var id identity.Identity id.NID = uuid.Must(uuid.NewV4()) require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) _ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), id.ID) browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, &id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, isSPA, publicTS) // We inject the session to replay interim, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) interim.InternalContext = settingsFixtureSuccessInternalContext require.NoError(t, reg.SettingsFlowPersister().UpdateSettingsFlow(context.Background(), interim)) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) // We use the response replay values.Set(node.WebAuthnRegister, string(settingsFixtureSuccessResponse)) values.Set(node.WebAuthnRegisterDisplayName, "foobar") actual, _ := testhelpers.SettingsMakeRequest(t, false, isSPA, f, browserClient, testhelpers.EncodeFormAsJSON(t, isSPA, values)) assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) }) } }) }