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
644 lines
25 KiB
Go
644 lines
25 KiB
Go
// 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)
|
|
})
|
|
}
|
|
})
|
|
}
|