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
1030 lines
43 KiB
Go
1030 lines
43 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package webauthn_test
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"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/testhelpers"
|
|
"github.com/ory/kratos/selfservice/flow"
|
|
"github.com/ory/kratos/selfservice/flow/login"
|
|
"github.com/ory/kratos/selfservice/strategy/idfirst"
|
|
"github.com/ory/kratos/selfservice/strategy/webauthn"
|
|
"github.com/ory/kratos/text"
|
|
"github.com/ory/kratos/ui/node"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/x/assertx"
|
|
"github.com/ory/x/contextx"
|
|
"github.com/ory/x/jsonx"
|
|
"github.com/ory/x/snapshotx"
|
|
)
|
|
|
|
var (
|
|
//go:embed fixtures/login/success/mfa/response.invalid.json
|
|
loginFixtureSuccessResponseInvalid []byte
|
|
//go:embed fixtures/login/success/mfa/identity.json
|
|
loginFixtureSuccessIdentity []byte
|
|
//go:embed fixtures/login/success/mfa/v0/credentials.json
|
|
loginFixtureSuccessV0Credentials []byte
|
|
//go:embed fixtures/login/success/mfa/v0/internal_context.json
|
|
loginFixtureSuccessV0Context []byte
|
|
//go:embed fixtures/login/success/mfa/v0/response.json
|
|
loginFixtureSuccessV0Response []byte
|
|
//go:embed fixtures/login/success/mfa/v1/credentials.json
|
|
loginFixtureSuccessV1Credentials []byte
|
|
//go:embed fixtures/login/success/mfa/v1/internal_context.json
|
|
loginFixtureSuccessV1Context []byte
|
|
//go:embed fixtures/login/success/mfa/v1/response.json
|
|
loginFixtureSuccessV1Response []byte
|
|
//go:embed fixtures/login/success/mfa/v1_handle/credentials.json
|
|
loginFixtureSuccessV1WithHandleCredentials []byte
|
|
//go:embed fixtures/login/success/mfa/v1_handle/internal_context.json
|
|
loginFixtureSuccessV1WithHandleContext []byte
|
|
//go:embed fixtures/login/success/mfa/v1_handle/response.json
|
|
loginFixtureSuccessV1WithHandleResponse []byte
|
|
//go:embed fixtures/login/success/mfa/v1_passwordless/credentials.json
|
|
loginFixtureSuccessV1PasswordlessCredentials []byte
|
|
//go:embed fixtures/login/success/mfa/v1_passwordless/internal_context.json
|
|
loginFixtureSuccessV1PasswordlessContext []byte
|
|
//go:embed fixtures/login/success/mfa/v1_passwordless/response.json
|
|
loginFixtureSuccessV1PasswordlessResponse []byte
|
|
)
|
|
|
|
var loginFixtureSuccessEmail = gjson.GetBytes(loginFixtureSuccessIdentity, "traits.email").String()
|
|
|
|
func TestCompleteLogin(t *testing.T) {
|
|
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
|
configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword)+".enabled", false),
|
|
enabledWebauthn,
|
|
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
|
|
)
|
|
|
|
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
|
|
|
_ = testhelpers.NewErrorTestServer(t, reg)
|
|
uiTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
|
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
|
|
|
checkURL := func(t *testing.T, shouldRedirect bool, res *http.Response) {
|
|
if shouldRedirect {
|
|
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
|
}
|
|
}
|
|
|
|
doAPIFlow := func(t *testing.T, v func(url.Values), apiClient *http.Client, opts ...testhelpers.InitFlowWithOption) (string, *http.Response) {
|
|
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, opts...)
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
v(values)
|
|
payload := testhelpers.EncodeFormAsJSON(t, true, values)
|
|
return testhelpers.LoginMakeRequest(t, true, false, f, apiClient, payload)
|
|
}
|
|
|
|
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), browserClient *http.Client, opts ...testhelpers.InitFlowWithOption) (string, *http.Response) {
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, spa, false, false, opts...)
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
v(values)
|
|
return testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
|
|
}
|
|
|
|
createIdentityWithWebAuthn := func(t *testing.T, c identity.Credentials) *identity.Identity {
|
|
var id identity.Identity
|
|
require.NoError(t, json.Unmarshal(loginFixtureSuccessIdentity, &id))
|
|
|
|
id.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{
|
|
Identifiers: []string{loginFixtureSuccessEmail},
|
|
Config: c.Config,
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Version: c.Version,
|
|
})
|
|
|
|
// We clean up the identity in case it has been created before
|
|
_ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), id.ID)
|
|
|
|
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &id))
|
|
return &id
|
|
}
|
|
|
|
submitWebAuthnLoginFlowWithClient := func(t *testing.T, isSPA bool, f *kratos.LoginFlow, contextFixture []byte, client *http.Client, cb func(values url.Values)) (string, *http.Response, *kratos.LoginFlow) {
|
|
// We inject the session to replay
|
|
interim, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
interim.InternalContext = contextFixture
|
|
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), interim))
|
|
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
cb(values)
|
|
|
|
// We use the response replay
|
|
body, res := testhelpers.LoginMakeRequest(t, false, isSPA, f, client, values.Encode())
|
|
return body, res, f
|
|
}
|
|
|
|
submitWebAuthnLoginWithClient := func(t *testing.T, isSPA bool, id *identity.Identity, contextFixture []byte, client *http.Client, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, publicTS, false, isSPA, false, false, opts...)
|
|
return submitWebAuthnLoginFlowWithClient(t, isSPA, f, contextFixture, client, cb)
|
|
}
|
|
|
|
submitWebAuthnLogin := func(t *testing.T, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
|
|
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
|
return submitWebAuthnLoginWithClient(t, isSPA, id, contextFixture, browserClient, cb, opts...)
|
|
}
|
|
|
|
t.Run("flow=refresh", func(t *testing.T) {
|
|
conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
|
|
t.Cleanup(func() {
|
|
conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, nil)
|
|
})
|
|
|
|
run := func(t *testing.T, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel, expectTriggers bool) {
|
|
body, res, f := submitWebAuthnLogin(t, isSPA, id, context, func(values url.Values) {
|
|
values.Set("identifier", loginFixtureSuccessEmail)
|
|
values.Set(node.WebAuthnLogin, string(response))
|
|
},
|
|
testhelpers.InitFlowWithRefresh(),
|
|
testhelpers.InitFlowWithAAL(expectedAAL),
|
|
)
|
|
snapshotx.SnapshotTExcept(t, f.Ui.Nodes, []string{
|
|
"0.attributes.value",
|
|
"3.attributes.nonce",
|
|
"3.attributes.src",
|
|
"4.attributes.value",
|
|
"4.attributes.onclick",
|
|
})
|
|
|
|
nodes, err := json.Marshal(f.Ui.Nodes)
|
|
require.NoError(t, err)
|
|
|
|
if !expectTriggers {
|
|
assert.Falsef(t, gjson.GetBytes(nodes, "#(attributes.name==identifier)").Exists(), "%s", nodes)
|
|
return
|
|
}
|
|
|
|
assert.Equal(t, loginFixtureSuccessEmail, gjson.GetBytes(nodes, "#(attributes.name==identifier).attributes.value").String(), "%s", nodes)
|
|
|
|
prefix := ""
|
|
if isSPA {
|
|
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow, "%s", body)
|
|
prefix = "session."
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), redirTS.URL, "%s", body)
|
|
}
|
|
|
|
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
|
|
|
|
assert.EqualValues(t, expectedAAL, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
|
|
assert.EqualValues(t, identity.CredentialsTypeWebAuthn, gjson.Get(body, prefix+"authentication_methods.#(method==webauthn).method").String(), "%s", body)
|
|
assert.Len(t, gjson.Get(body, prefix+"authentication_methods").Array(), 2, "%s", body)
|
|
assert.EqualValues(t, id.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("case=passwordless", func(t *testing.T) {
|
|
for _, e := range []bool{
|
|
true,
|
|
false,
|
|
} {
|
|
conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, e)
|
|
expectedAAL := identity.AuthenticatorAssuranceLevel1
|
|
if !e {
|
|
// If passwordless is disabled, using WebAuthn means that we have a second factor enabled.
|
|
// Thus, AAL2 :)
|
|
expectedAAL = identity.AuthenticatorAssuranceLevel2
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
creds identity.Credentials
|
|
response []byte
|
|
context []byte
|
|
descript string
|
|
expectTriggers bool
|
|
}{
|
|
{
|
|
creds: identity.Credentials{
|
|
Config: loginFixtureSuccessV0Credentials,
|
|
Version: 0,
|
|
},
|
|
context: loginFixtureSuccessV0Context,
|
|
response: loginFixtureSuccessV0Response,
|
|
descript: "mfa v0 credentials",
|
|
expectTriggers: !e,
|
|
},
|
|
{
|
|
creds: identity.Credentials{
|
|
Config: loginFixtureSuccessV1Credentials,
|
|
Version: 1,
|
|
},
|
|
context: loginFixtureSuccessV1Context,
|
|
response: loginFixtureSuccessV1Response,
|
|
descript: "mfa v1 credentials",
|
|
expectTriggers: !e,
|
|
},
|
|
{
|
|
creds: identity.Credentials{
|
|
Config: loginFixtureSuccessV1PasswordlessCredentials,
|
|
Version: 1,
|
|
},
|
|
context: loginFixtureSuccessV1PasswordlessContext,
|
|
response: loginFixtureSuccessV1PasswordlessResponse,
|
|
descript: "passwordless credentials",
|
|
expectTriggers: e,
|
|
},
|
|
} {
|
|
t.Run(fmt.Sprintf("passwordless enabled=%v/case=%s", e, tc.descript), func(t *testing.T) {
|
|
id := createIdentityWithWebAuthn(t, tc.creds)
|
|
|
|
for _, f := range []string{
|
|
"browser",
|
|
"spa",
|
|
} {
|
|
t.Run(f, func(t *testing.T) {
|
|
run(t, id, tc.context, tc.response, f == "spa", expectedAAL, tc.expectTriggers)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("case=no webauth credentials", func(t *testing.T) {
|
|
for _, e := range []bool{true, false} {
|
|
conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, e)
|
|
t.Run(fmt.Sprintf("passwordless=%v", e), func(t *testing.T) {
|
|
for _, f := range []string{"browser", "spa"} {
|
|
t.Run(f, func(t *testing.T) {
|
|
id := identity.NewIdentity("")
|
|
id.NID = x.NewUUID()
|
|
client := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
|
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, publicTS, true, f == "spa", false, false)
|
|
snapshotx.SnapshotTExcept(t, f.Ui.Nodes, []string{
|
|
"0.attributes.value",
|
|
})
|
|
nodes, err := json.Marshal(f.Ui.Nodes)
|
|
require.NoError(t, err)
|
|
assert.False(t, gjson.GetBytes(nodes, "#(attributes.name==identifier).attributes.value").Bool(), "%s", nodes)
|
|
assert.False(t, gjson.GetBytes(nodes, "#(attributes.name=="+node.WebAuthnLoginTrigger+").attributes.value").Bool(), "%s", nodes)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("flow=passwordless", func(t *testing.T) {
|
|
conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, true)
|
|
t.Cleanup(func() {
|
|
conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, false)
|
|
})
|
|
|
|
t.Run("case=webauthn button exists", func(t *testing.T) {
|
|
client := testhelpers.NewClientWithCookies(t)
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, publicTS, false, true, false, false)
|
|
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"})
|
|
})
|
|
|
|
t.Run("case=webauthn shows error if user tries to sign in but no such user exists", func(t *testing.T) {
|
|
payload := func(v url.Values) {
|
|
v.Set("method", identity.CredentialsTypeWebAuthn.String())
|
|
v.Set("identifier", "doesnotexist")
|
|
}
|
|
|
|
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
|
checkURL(t, shouldRedirect, res)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, text.NewErrorValidationSuchNoWebAuthnUser().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, false, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, true, body, res)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, true, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, false, body, res)
|
|
})
|
|
})
|
|
|
|
t.Run("case=webauthn shows error if user tries to sign in but user has no webauth credentials set up", func(t *testing.T) {
|
|
id, subject := createIdentityAndReturnIdentifier(t.Context(), t, reg, nil)
|
|
id.DeleteCredentialsType(identity.CredentialsTypeWebAuthn)
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), id, identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
payload := func(v url.Values) {
|
|
v.Set("method", identity.CredentialsTypeWebAuthn.String())
|
|
v.Set("identifier", subject)
|
|
}
|
|
|
|
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
|
checkURL(t, shouldRedirect, res)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, text.NewErrorValidationSuchNoWebAuthnUser().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, false, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, true, body, res)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, true, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, false, body, res)
|
|
})
|
|
})
|
|
|
|
t.Run("case=webauthn MFA credentials can not be used for passwordless login", func(t *testing.T) {
|
|
_, subject := createIdentityAndReturnIdentifier(t.Context(), t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","is_passwordless":false}]}`))
|
|
|
|
payload := func(v url.Values) {
|
|
v.Set("method", identity.CredentialsTypeWebAuthn.String())
|
|
v.Set("identifier", subject)
|
|
}
|
|
|
|
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
|
checkURL(t, shouldRedirect, res)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, text.NewErrorValidationSuchNoWebAuthnUser().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, false, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, true, body, res)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
body, res := doBrowserFlow(t, true, payload, testhelpers.NewClientWithCookies(t))
|
|
check(t, false, body, res)
|
|
})
|
|
})
|
|
|
|
t.Run("case=should fail if webauthn login is invalid", func(t *testing.T) {
|
|
_, subject := createIdentityAndReturnIdentifier(t.Context(), t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`))
|
|
|
|
doBrowserFlow := func(t *testing.T, spa bool, browserClient *http.Client, opts ...testhelpers.InitFlowWithOption) {
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, spa, false, false, opts...)
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
|
|
values.Set("method", identity.CredentialsTypeWebAuthn.String())
|
|
values.Set("identifier", subject)
|
|
body, res := testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
|
|
if spa {
|
|
assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
|
|
redir := gjson.Get(body, "redirect_browser_to").String()
|
|
assert.NotEmpty(t, redir)
|
|
|
|
res, err := browserClient.Get(redir)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = res.Body.Close() }()
|
|
raw, err := io.ReadAll(res.Body)
|
|
require.NoError(t, err)
|
|
body = string(raw)
|
|
} else {
|
|
checkURL(t, !spa, res)
|
|
}
|
|
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptNestedKeys("value", "src", "nonce", "action", "request_url", "issued_at", "expires_at", "created_at", "updated_at", "id", "onclick"))
|
|
assert.Equal(t, text.NewInfoLoginWebAuthnPasswordless().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
|
|
values.Set(node.WebAuthnLogin, string(loginFixtureSuccessResponseInvalid))
|
|
values.Set("identifier", subject)
|
|
body, res = testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
|
|
checkURL(t, !spa, res)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, "The provided web authn login is invalid, please try again.", gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
doBrowserFlow(t, false, testhelpers.NewClientWithCookies(t))
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
doBrowserFlow(t, true, testhelpers.NewClientWithCookies(t))
|
|
})
|
|
})
|
|
|
|
t.Run("case=succeeds with passwordless login", func(t *testing.T) {
|
|
run := func(t *testing.T, spa bool) {
|
|
conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
|
|
// We load our identity which we will use to replay the webauth session
|
|
id := createIdentityWithWebAuthn(t, identity.Credentials{
|
|
Config: loginFixtureSuccessV1PasswordlessCredentials,
|
|
Version: 1,
|
|
})
|
|
|
|
browserClient := testhelpers.NewClientWithCookies(t)
|
|
body, res, f := submitWebAuthnLoginWithClient(t, spa, id, loginFixtureSuccessV1PasswordlessContext, browserClient, func(values url.Values) {
|
|
values.Set("identifier", loginFixtureSuccessEmail)
|
|
values.Set(node.WebAuthnLogin, string(loginFixtureSuccessV1PasswordlessResponse))
|
|
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel1))
|
|
|
|
prefix := ""
|
|
if spa {
|
|
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
|
prefix = "session."
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), redirTS.URL)
|
|
}
|
|
|
|
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
|
|
assert.EqualValues(t, identity.CredentialsTypeWebAuthn, gjson.Get(body, prefix+"authentication_methods.#(method==webauthn).method").String(), "%s", body)
|
|
assert.EqualValues(t, id.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
|
|
|
|
actualFlow, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData)))
|
|
|
|
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(), conf.SelfServiceBrowserDefaultReturnTo(t.Context()).String(), "%s", body)
|
|
} else {
|
|
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%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("flow=mfa", func(t *testing.T) {
|
|
t.Run("case=webauthn payload is set when identity has webauthn", func(t *testing.T) {
|
|
id := createIdentity(t.Context(), t, reg)
|
|
|
|
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, apiClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
assert.Equal(t, gjson.GetBytes(id.Traits, "subject").String(), f.Ui.Nodes[1].Attributes.UiNodeInputAttributes.Value, jsonx.TestMarshalJSONString(t, f.Ui))
|
|
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
|
|
"0.attributes.value",
|
|
"1.attributes.value",
|
|
"3.attributes.src",
|
|
"3.attributes.nonce",
|
|
"4.attributes.onclick",
|
|
"4.attributes.onload",
|
|
"4.attributes.value",
|
|
})
|
|
ensureReplacement(t, "4", f.Ui, "allowCredentials")
|
|
})
|
|
|
|
t.Run("case=webauthn payload is not set when identity has no webauthn", func(t *testing.T) {
|
|
id := createIdentityWithoutWebAuthn(t, reg)
|
|
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, apiClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
|
|
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
|
|
"0.attributes.value",
|
|
})
|
|
})
|
|
|
|
t.Run("case=webauthn payload is not set for API clients", func(t *testing.T) {
|
|
id := createIdentity(t.Context(), t, reg)
|
|
|
|
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
|
|
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
assertx.EqualAsJSON(t, nil, f.Ui.Nodes)
|
|
})
|
|
|
|
doAPIFlowSignedIn := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
|
return doAPIFlow(t, v, testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
}
|
|
|
|
doBrowserFlowSignIn := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
|
return doBrowserFlow(t, spa, v, testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
}
|
|
|
|
t.Run("case=should refuse to execute api flow", func(t *testing.T) {
|
|
id := createIdentity(t.Context(), t, reg)
|
|
payload := func(v url.Values) {
|
|
v.Set(node.WebAuthnLogin, "{}")
|
|
}
|
|
|
|
body, res := doAPIFlowSignedIn(t, payload, id)
|
|
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, "Could not find a strategy to log you in with. Did you fill out the form correctly?", gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
})
|
|
|
|
t.Run("case=should fail if webauthn login is invalid", func(t *testing.T) {
|
|
id, sub := createIdentityAndReturnIdentifier(t.Context(), t, reg, nil)
|
|
payload := func(v url.Values) {
|
|
v.Set("identifier", sub)
|
|
v.Set(node.WebAuthnLogin, string(loginFixtureSuccessResponseInvalid))
|
|
}
|
|
|
|
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
|
checkURL(t, shouldRedirect, res)
|
|
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
|
assert.Equal(t, "The provided web authn login is invalid, please try again.", gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
|
}
|
|
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
body, res := doBrowserFlowSignIn(t, false, payload, id)
|
|
check(t, true, body, res)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
body, res := doBrowserFlowSignIn(t, true, payload, id)
|
|
check(t, false, body, res)
|
|
})
|
|
})
|
|
|
|
t.Run("case=can not use security key for passwordless in mfa flow", func(t *testing.T) {
|
|
id := createIdentityWithWebAuthn(t, identity.Credentials{
|
|
Config: loginFixtureSuccessV1PasswordlessCredentials,
|
|
Version: 1,
|
|
})
|
|
|
|
body, res, _ := submitWebAuthnLogin(t, true, id, loginFixtureSuccessV1PasswordlessContext, func(values url.Values) {
|
|
values.Set("identifier", loginFixtureSuccessEmail)
|
|
values.Set(node.WebAuthnLogin, string(loginFixtureSuccessV1PasswordlessResponse))
|
|
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
|
snapshotx.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.messages").Raw), []string{})
|
|
})
|
|
|
|
t.Run("case=login with a security key using", func(t *testing.T) {
|
|
idd := uuid.FromStringOrNil("44fc22c9-abae-4c3e-a56b-37c7b38d973e")
|
|
out, err := json.Marshal(identity.CredentialsWebAuthnConfig{UserHandle: idd[:]})
|
|
require.NoError(t, err)
|
|
t.Logf("json: %s", out)
|
|
out, err = json.Marshal(protocol.AuthenticatorAssertionResponse{UserHandle: idd[:]})
|
|
require.NoError(t, err)
|
|
t.Logf("wa: %s", out)
|
|
|
|
for _, tc := range []struct {
|
|
d string
|
|
v int
|
|
cf []byte
|
|
sf []byte
|
|
ix []byte
|
|
}{
|
|
{d: "v0 without userhandle", v: 0, cf: loginFixtureSuccessV0Credentials, sf: loginFixtureSuccessV0Response, ix: loginFixtureSuccessV0Context},
|
|
{d: "v1 without userhandle", v: 1, cf: loginFixtureSuccessV1Credentials, sf: loginFixtureSuccessV1Response, ix: loginFixtureSuccessV1Context},
|
|
{d: "v1 with differing userhandle", v: 1, cf: loginFixtureSuccessV1WithHandleCredentials, sf: loginFixtureSuccessV1WithHandleResponse, ix: loginFixtureSuccessV1WithHandleContext},
|
|
} {
|
|
t.Run(tc.d, func(t *testing.T) {
|
|
run := func(t *testing.T, spa bool) {
|
|
// We load our identity which we will use to replay the webauth session
|
|
id := createIdentityWithWebAuthn(t, identity.Credentials{
|
|
Config: tc.cf,
|
|
Version: tc.v,
|
|
})
|
|
|
|
body, res, f := submitWebAuthnLogin(t, spa, id, tc.ix, func(values url.Values) {
|
|
values.Set("identifier", loginFixtureSuccessEmail)
|
|
values.Set(node.WebAuthnLogin, string(tc.sf))
|
|
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
|
|
prefix := ""
|
|
if spa {
|
|
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
|
prefix = "session."
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), redirTS.URL)
|
|
}
|
|
|
|
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
|
|
assert.EqualValues(t, identity.CredentialsTypeWebAuthn, gjson.Get(body, prefix+"authentication_methods.#(method==webauthn).method").String(), "%s", body)
|
|
assert.EqualValues(t, id.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
|
|
|
|
actualFlow, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData)))
|
|
}
|
|
|
|
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 be able to log in with phone number identifier", func(t *testing.T) {
|
|
// Test backward compatibility with a legacy identity whose credential
|
|
// identifier is stored in non-normalized form in the database.
|
|
// Note: AAL2 (MFA) login resolves the identity from the session, not via
|
|
// credential identifier lookup, so both normalized and non-normalized
|
|
// identifiers succeed regardless of the DB state.
|
|
nonNormalizedPhone := "+44 20 7946 0958"
|
|
normalizedPhone := "+442079460958"
|
|
|
|
phoneID := &identity.Identity{
|
|
Traits: identity.Traits(fmt.Sprintf(`{"phone":"%s"}`, nonNormalizedPhone)),
|
|
}
|
|
phoneID.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{
|
|
Identifiers: []string{nonNormalizedPhone},
|
|
Config: loginFixtureSuccessV1Credentials,
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Version: 1,
|
|
})
|
|
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), phoneID))
|
|
t.Cleanup(func() {
|
|
_ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), phoneID.ID)
|
|
})
|
|
|
|
// CreateIdentity normalizes the credential identifier to E.164 format and
|
|
// may update the credential version. To simulate a legacy database record
|
|
// created before normalization was introduced, revert both via raw SQL.
|
|
require.NoError(t, reg.Persister().GetConnection(t.Context()).RawQuery(
|
|
"UPDATE identity_credential_identifiers SET identifier = ? WHERE identity_id = ? AND identifier = ?",
|
|
nonNormalizedPhone, phoneID.ID, normalizedPhone,
|
|
).Exec())
|
|
require.NoError(t, reg.Persister().GetConnection(t.Context()).RawQuery(
|
|
"UPDATE identity_credentials SET version = 0 WHERE identity_id = ?",
|
|
phoneID.ID,
|
|
).Exec())
|
|
|
|
run := func(t *testing.T, spa bool, identifier string) {
|
|
body, res, f := submitWebAuthnLogin(t, spa, phoneID, loginFixtureSuccessV1Context, func(values url.Values) {
|
|
values.Set("identifier", identifier)
|
|
values.Set(node.WebAuthnLogin, string(loginFixtureSuccessV1Response))
|
|
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
|
|
prefix := ""
|
|
if spa {
|
|
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
|
prefix = "session."
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), redirTS.URL)
|
|
}
|
|
|
|
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
|
|
assert.EqualValues(t, phoneID.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
|
|
|
|
actualFlow, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData)))
|
|
}
|
|
|
|
t.Run("identifier=non-normalized", func(t *testing.T) {
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
run(t, false, nonNormalizedPhone)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
run(t, true, nonNormalizedPhone)
|
|
})
|
|
})
|
|
|
|
t.Run("identifier=normalized", func(t *testing.T) {
|
|
t.Run("type=browser", func(t *testing.T) {
|
|
run(t, false, normalizedPhone)
|
|
})
|
|
|
|
t.Run("type=spa", func(t *testing.T) {
|
|
run(t, true, normalizedPhone)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestFormHydration(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, reg := pkg.NewFastRegistryWithMocks(t)
|
|
|
|
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".enabled", true)
|
|
ctx = contextx.WithConfigValue(
|
|
ctx,
|
|
config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config",
|
|
map[string]interface{}{
|
|
"rp": map[string]interface{}{
|
|
"display_name": "foo",
|
|
"id": "localhost",
|
|
"origins": []string{"http://localhost"},
|
|
},
|
|
},
|
|
)
|
|
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/login.schema.json")
|
|
|
|
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeWebAuthn)
|
|
require.NoError(t, err)
|
|
fhAAL1, ok := s.(login.AAL1FormHydrator)
|
|
require.True(t, ok)
|
|
fhAAL2, ok := s.(login.AAL2FormHydrator)
|
|
require.True(t, ok)
|
|
|
|
toSnapshot := func(t *testing.T, f *login.Flow) {
|
|
t.Helper()
|
|
// The CSRF token has a unique value that messes with the snapshot - ignore it.
|
|
f.UI.Nodes.ResetNodes("csrf_token")
|
|
f.UI.Nodes.ResetNodes("identifier")
|
|
f.UI.Nodes.ResetNodes("webauthn_login_trigger")
|
|
snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("onclick", "nonce", "src"))
|
|
}
|
|
|
|
newFlowInternal := func(ctx context.Context, t *testing.T, identitySchema string) (*http.Request, *login.Flow) {
|
|
query := ""
|
|
if identitySchema != "" {
|
|
query = "?identity_schema=" + identitySchema
|
|
}
|
|
|
|
r := httptest.NewRequest("GET", "/self-service/login/browser"+query, nil)
|
|
r = r.WithContext(ctx)
|
|
t.Helper()
|
|
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
|
|
f.UI.Nodes = make(node.Nodes, 0)
|
|
require.NoError(t, err)
|
|
return r, f
|
|
}
|
|
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) {
|
|
return newFlowInternal(ctx, t, "")
|
|
}
|
|
newFlowWithIdentitySchema := func(ctx context.Context, t *testing.T, identitySchema string) (*http.Request, *login.Flow) {
|
|
return newFlowInternal(ctx, t, identitySchema)
|
|
}
|
|
|
|
passwordlessEnabled := contextx.WithConfigValue(ctx, config.ViperKeyWebAuthnPasswordless, true)
|
|
mfaEnabled := contextx.WithConfigValue(ctx, config.ViperKeyWebAuthnPasswordless, false)
|
|
|
|
t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) {
|
|
id := createIdentity(ctx, t, reg)
|
|
headers := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
|
|
r.Header = headers
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
|
|
|
require.NoError(t, fhAAL2.PopulateLoginMethodSecondFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlow(mfaEnabled, t)
|
|
|
|
r.Header = headers
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
|
|
|
require.NoError(t, fhAAL2.PopulateLoginMethodSecondFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlow(mfaEnabled, t)
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("method=PopulateLoginMethodRefresh", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled but user has no passwordless credentials", func(t *testing.T) {
|
|
id := createIdentity(ctx, t, reg)
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
|
f.Refresh = true
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=passwordless enabled and user has passwordless credentials", func(t *testing.T) {
|
|
id, _ := createIdentityAndReturnIdentifier(ctx, t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`))
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
|
f.Refresh = true
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled and user has mfa credentials", func(t *testing.T) {
|
|
id := createIdentity(ctx, t, reg)
|
|
r, f := newFlow(mfaEnabled, t)
|
|
r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
|
f.Refresh = true
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled but user has passwordless credentials", func(t *testing.T) {
|
|
id, _ := createIdentityAndReturnIdentifier(ctx, t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`))
|
|
r, f := newFlow(mfaEnabled, t)
|
|
r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
|
f.Refresh = true
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) {
|
|
t.Run("case=no options", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
})
|
|
|
|
t.Run("case=WithIdentifier", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
|
r, f := newFlow(
|
|
contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true),
|
|
t,
|
|
)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
})
|
|
|
|
t.Run("case=WithIdentityHint", func(t *testing.T) {
|
|
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
|
mfaEnabled := contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true)
|
|
passwordlessEnabled := contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true)
|
|
|
|
id := identity.NewIdentity("test-provider")
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlow(mfaEnabled, t)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
|
mfaEnabled := contextx.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false)
|
|
passwordlessEnabled := contextx.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false)
|
|
|
|
id, _ := createIdentityAndReturnIdentifier(ctx, t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`))
|
|
|
|
t.Run("case=identity has webauthn", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlow(mfaEnabled, t)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("case=identity does not have a webauthn", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
id := identity.NewIdentity("default")
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
id := identity.NewIdentity("default")
|
|
r, f := newFlow(mfaEnabled, t)
|
|
require.ErrorIs(t, fhAAL1.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) {
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlow(passwordlessEnabled, t)
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodIdentifierFirstIdentification(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlow(mfaEnabled, t)
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodIdentifierFirstIdentification(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
|
|
t.Run("case=Multi-Schema-method=PopulateLoginMethodFirstFactor", func(t *testing.T) {
|
|
multiSchema := contextx.WithConfigValue(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
|
|
multiSchema = contextx.WithConfigValue(multiSchema, config.ViperKeyIdentitySchemas, config.Schemas{
|
|
{ID: "default", URL: "file://./stub/missing-identifier.schema.json"},
|
|
{ID: "not-default", URL: "file://./stub/login.schema.json", SelfserviceSelectable: true},
|
|
})
|
|
|
|
t.Run("case=passwordless enabled", func(t *testing.T) {
|
|
r, f := newFlowWithIdentitySchema(contextx.WithConfigValue(multiSchema, config.ViperKeyWebAuthnPasswordless, true), t, "not-default")
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
|
|
t.Run("case=mfa enabled", func(t *testing.T) {
|
|
r, f := newFlowWithIdentitySchema(contextx.WithConfigValue(multiSchema, config.ViperKeyWebAuthnPasswordless, false), t, "not-default")
|
|
require.NoError(t, fhAAL1.PopulateLoginMethodFirstFactor(r, f))
|
|
toSnapshot(t, f)
|
|
})
|
|
})
|
|
}
|