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
1183 lines
53 KiB
Go
1183 lines
53 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package identity_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/x/configx"
|
|
"github.com/ory/x/sqlcon"
|
|
|
|
_ "embed"
|
|
|
|
"github.com/gofrs/uuid"
|
|
|
|
"github.com/ory/x/sqlxx"
|
|
|
|
"github.com/ory/kratos/pkg/testhelpers"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/ory/kratos/courier"
|
|
"github.com/ory/kratos/courier/template"
|
|
"github.com/ory/kratos/driver/config"
|
|
"github.com/ory/kratos/identity"
|
|
"github.com/ory/kratos/pkg"
|
|
"github.com/ory/kratos/x"
|
|
)
|
|
|
|
//go:embed stub/aal.json
|
|
var refreshAALStubs []byte
|
|
|
|
func TestManager(t *testing.T) {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t,
|
|
configx.WithValues(map[string]interface{}{
|
|
config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/",
|
|
config.ViperKeySelfServiceRegistrationLoginHints: true,
|
|
config.ViperKeyDefaultIdentitySchemaID: "default",
|
|
}),
|
|
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
|
|
"default": "file://./stub/manager.schema.json",
|
|
"extension": "file://./stub/extension.schema.json",
|
|
})),
|
|
)
|
|
|
|
t.Run("case=should fail to create because validation fails", func(t *testing.T) {
|
|
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
i.Traits = identity.Traits(`{"email":"not an email"}`)
|
|
require.Error(t, reg.IdentityManager().Create(t.Context(), i))
|
|
})
|
|
|
|
newTraits := func(email string, unprotected string) identity.Traits {
|
|
return identity.Traits(fmt.Sprintf(`{"email":"%[1]s","email_verify":"%[1]s","email_recovery":"%[1]s","email_creds":"%[1]s","unprotected": "%[2]s"}`, email, unprotected))
|
|
}
|
|
|
|
checkExtensionFields := func(i *identity.Identity, expected string) func(*testing.T) {
|
|
return func(t *testing.T) {
|
|
require.Len(t, i.VerifiableAddresses, 1)
|
|
assert.EqualValues(t, expected, i.VerifiableAddresses[0].Value)
|
|
assert.EqualValues(t, identity.AddressTypeEmail, i.VerifiableAddresses[0].Via)
|
|
|
|
require.Len(t, i.RecoveryAddresses, 1)
|
|
assert.EqualValues(t, expected, i.RecoveryAddresses[0].Value)
|
|
assert.EqualValues(t, identity.AddressTypeEmail, i.RecoveryAddresses[0].Via)
|
|
|
|
require.NotNil(t, i.Credentials[identity.CredentialsTypePassword])
|
|
assert.Equal(t, []string{expected}, i.Credentials[identity.CredentialsTypePassword].Identifiers)
|
|
}
|
|
}
|
|
|
|
checkExtensionFieldsForIdentities := func(t *testing.T, expected string, original *identity.Identity) {
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
identities := []identity.Identity{*original, *fromStore}
|
|
for k := range identities {
|
|
t.Run(fmt.Sprintf("identity=%d", k), checkExtensionFields(&identities[k], expected))
|
|
}
|
|
}
|
|
|
|
t.Run("method=CreateIdentities", func(t *testing.T) {
|
|
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
// By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint.
|
|
// This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct.
|
|
Identifiers: []string{},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
identity.CredentialsTypeTOTP: {
|
|
Type: identity.CredentialsTypeTOTP,
|
|
// By explicitly not setting the identifier, we mimic the behavior of the PATCH endpoint.
|
|
// This tests a bug we introduced on the PATCH endpoint where the AAL value would not be correct.
|
|
Identifiers: []string{},
|
|
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().CreateIdentities(t.Context(), []*identity.Identity{original}))
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing)
|
|
require.NoError(t, err)
|
|
|
|
got, ok := fromStore.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
|
|
})
|
|
})
|
|
|
|
t.Run("method=Create", func(t *testing.T) {
|
|
t.Run("case=should create identity and track extension fields", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
checkExtensionFieldsForIdentities(t, email, original)
|
|
got, ok := original.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got)
|
|
})
|
|
|
|
t.Run("case=correctly set AAL", func(t *testing.T) {
|
|
t.Run("case=should set AAL to 0 if no credentials are available", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
got, ok := original.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, got)
|
|
})
|
|
|
|
t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
got, ok := original.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, got)
|
|
})
|
|
|
|
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
identity.CredentialsTypeTOTP: {
|
|
Type: identity.CredentialsTypeTOTP,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
got, ok := original.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
|
|
})
|
|
|
|
t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeTOTP: {
|
|
Type: identity.CredentialsTypeTOTP,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
got, ok := original.InternalAvailableAAL.ToAAL()
|
|
require.True(t, ok)
|
|
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, got)
|
|
})
|
|
})
|
|
|
|
t.Run("case=should expose validation errors with option", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = identity.Traits(`{"email":"not an email"}`)
|
|
err := reg.IdentityManager().Create(t.Context(), original, identity.ManagerExposeValidationErrorsForInternalTypeAssertion)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "\"not an email\" is not valid \"email\"")
|
|
})
|
|
|
|
t.Run("case=should not expose validation errors without option", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = identity.Traits(`{"email":"not an email"}`)
|
|
err := reg.IdentityManager().Create(t.Context(), original)
|
|
require.Error(t, err)
|
|
assert.NotContains(t, err.Error(), "\"not an email\" is not valid \"email\"")
|
|
})
|
|
|
|
t.Run("case=should correctly hint at the duplicate credential", func(t *testing.T) {
|
|
createIdentity := func(email string, field string, creds map[identity.CredentialsType]identity.Credentials) *identity.Identity {
|
|
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
i.Traits = identity.Traits(fmt.Sprintf(`{"%s":"%s"}`, field, email))
|
|
i.Credentials = creds
|
|
return i
|
|
}
|
|
|
|
t.Run("case=credential identifier duplicate", func(t *testing.T) {
|
|
t.Run("type=password", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, "email_creds", creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, "email_creds", creds)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.EqualValues(t, []string{identity.CredentialsTypePassword.String()}, verr.AvailableCredentials())
|
|
assert.Len(t, verr.AvailableOIDCProviders(), 0)
|
|
assert.Equal(t, verr.IdentifierHint(), email)
|
|
})
|
|
|
|
t.Run("type=webauthn", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeWebAuthn: {
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"credentials": [{"is_passwordless":true}]}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, "email_webauthn", creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, "email_webauthn", nil)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.EqualValues(t, []string{identity.CredentialsTypeWebAuthn.String()}, verr.AvailableCredentials())
|
|
assert.Len(t, verr.AvailableOIDCProviders(), 0)
|
|
assert.Equal(t, verr.IdentifierHint(), email)
|
|
})
|
|
|
|
t.Run("type=oidc", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeOIDC: {
|
|
Type: identity.CredentialsTypeOIDC,
|
|
// Identifiers in OIDC are not email addresses, but a unique user ID.
|
|
Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()},
|
|
Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, "email_creds", creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, "email_creds", creds)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.ElementsMatch(t, []string{"oidc"}, verr.AvailableCredentials())
|
|
assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders())
|
|
// The conflicting identifier is the oidc subject, which is not useful for the user
|
|
assert.Equal(t, email, verr.IdentifierHint())
|
|
})
|
|
|
|
t.Run("type=password+oidc+webauthn", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
identity.CredentialsTypeOIDC: {
|
|
Type: identity.CredentialsTypeOIDC,
|
|
// Identifiers in OIDC are not email addresses, but a unique user ID.
|
|
Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()},
|
|
Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`),
|
|
},
|
|
identity.CredentialsTypeWebAuthn: {
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"credentials": [{"is_passwordless":true}]}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, "email_creds", creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, "email_creds", creds)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.ElementsMatch(t, []string{"password", "oidc", "webauthn"}, verr.AvailableCredentials())
|
|
assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders())
|
|
assert.Equal(t, email, verr.IdentifierHint())
|
|
})
|
|
|
|
t.Run("type=code", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeCodeAuth: {
|
|
Type: identity.CredentialsTypeCodeAuth,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, "email_creds", creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, "email_creds", creds)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.EqualValues(t, []string{identity.CredentialsTypeCodeAuth.String()}, verr.AvailableCredentials())
|
|
assert.Len(t, verr.AvailableOIDCProviders(), 0)
|
|
assert.Equal(t, verr.IdentifierHint(), email)
|
|
})
|
|
})
|
|
|
|
runAddress := func(t *testing.T, field string) {
|
|
t.Run("case=password duplicate", func(t *testing.T) {
|
|
// This test mimics a case where an existing user with email + password exists, and the
|
|
// new user tries to sign up with a verification email (NOT email + password) that matches
|
|
// this existing record. Here, the end result is that we want to show the
|
|
// user: "Sign up with email foo@bar.com and your password instead."
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, field, creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, field, nil)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.EqualValues(t, []string{identity.CredentialsTypePassword.String()}, verr.AvailableCredentials())
|
|
assert.Len(t, verr.AvailableOIDCProviders(), 0)
|
|
assert.Equal(t, verr.IdentifierHint(), email)
|
|
})
|
|
|
|
t.Run("case=OIDC duplicate", func(t *testing.T) {
|
|
// This test mimics a case where user signed up using Social Sign In exists, and the
|
|
// new user tries to sign up with a verification email (NOT email + password) that matches
|
|
// this existing record (for example by using another social sign in provider.
|
|
// Here, the end result is that we want to show "Sign in using google instead".
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
creds := map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeOIDC: {
|
|
Type: identity.CredentialsTypeOIDC,
|
|
// Identifiers in OIDC are not email addresses, but a unique user ID.
|
|
Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()},
|
|
Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`),
|
|
},
|
|
}
|
|
|
|
first := createIdentity(email, field, creds)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), first))
|
|
|
|
second := createIdentity(email, field, nil)
|
|
err := reg.IdentityManager().Create(t.Context(), second)
|
|
require.Error(t, err)
|
|
|
|
verr := new(identity.ErrDuplicateCredentials)
|
|
assert.ErrorAs(t, err, &verr)
|
|
assert.EqualValues(t, []string{identity.CredentialsTypeOIDC.String()}, verr.AvailableCredentials())
|
|
assert.EqualValues(t, verr.AvailableOIDCProviders(), []string{"github", "google"})
|
|
assert.Equal(t, verr.IdentifierHint(), email)
|
|
})
|
|
}
|
|
|
|
t.Run("case=verifiable address", func(t *testing.T) {
|
|
runAddress(t, "email_verify")
|
|
})
|
|
|
|
t.Run("case=recovery address", func(t *testing.T) {
|
|
runAddress(t, "email_recovery")
|
|
})
|
|
})
|
|
})
|
|
|
|
t.Run("method=Update", func(t *testing.T) {
|
|
t.Run("case=should update identity and update extension fields", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits("baz@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
original.Traits = newTraits("bar@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
checkExtensionFieldsForIdentities(t, "bar@ory.sh", original)
|
|
})
|
|
|
|
t.Run("case=should reject credentials whose type does not match the map key", func(t *testing.T) {
|
|
// Safeguards the invariant from any caller that bypasses
|
|
// Identity.SetCredentials and writes to i.Credentials directly.
|
|
// The PATCH /admin/identities/{id} handler already enforces
|
|
// this; the Manager check is defense in depth.
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
})
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
// Bypass SetCredentials and write a malformed entry whose
|
|
// Type does not match the map key. This is what the patch
|
|
// handler effectively does when it decodes a JSON Patch
|
|
// straight into the Credentials map.
|
|
original.Credentials[identity.CredentialsTypePassword] = identity.Credentials{
|
|
Type: "",
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{}`),
|
|
}
|
|
|
|
err := reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits)
|
|
require.Error(t, err)
|
|
var herr *herodot.DefaultError
|
|
require.True(t, errors.As(err, &herr), "expected *herodot.DefaultError, got %T: %v", err, err)
|
|
assert.Equal(t, http.StatusBadRequest, herr.CodeField)
|
|
assert.Equal(t, `credentials.password.type must equal "password", got ""`, herr.Reason())
|
|
|
|
// Confirm the persisted credential row is byte-identical.
|
|
reloaded, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
cred := reloaded.Credentials[identity.CredentialsTypePassword]
|
|
assert.Equal(t, identity.CredentialsTypePassword, cred.Type)
|
|
assert.Equal(t, []string{email}, cred.Identifiers)
|
|
assert.JSONEq(t, `{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`, string(cred.Config))
|
|
})
|
|
|
|
t.Run("case=should not touch credentials excluded from the update", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
})
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
fromDB, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
dbCred := fromDB.Credentials[identity.CredentialsTypePassword]
|
|
|
|
// The in-memory password config diverges from the database, as if a
|
|
// concurrent locked credential-config write had won. The excluded
|
|
// type must survive the persist.
|
|
original.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"stale-in-memory"}`),
|
|
Version: dbCred.Version,
|
|
})
|
|
original.Traits = newTraits(email, "updated")
|
|
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original,
|
|
identity.ManagerAllowWriteProtectedTraits,
|
|
identity.ManagerWithoutCredentialTypes(identity.CredentialsTypePassword)))
|
|
|
|
reloaded, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
cred := reloaded.Credentials[identity.CredentialsTypePassword]
|
|
assert.Equal(t, dbCred.ID, cred.ID, "excluded credential must not be recreated")
|
|
assert.JSONEq(t, string(dbCred.Config), string(cred.Config), "excluded credential config must not change")
|
|
assert.JSONEq(t, string(newTraits(email, "updated")), string(reloaded.Traits), "traits must still update")
|
|
// The identity now carries the database state of the excluded type.
|
|
assert.JSONEq(t, string(dbCred.Config), string(original.Credentials[identity.CredentialsTypePassword].Config))
|
|
})
|
|
|
|
t.Run("case=should set AAL to 1 if password is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String)
|
|
})
|
|
|
|
t.Run("case=should set AAL to 2 if password and TOTP is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String)
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, original.InternalAvailableAAL.String, "Updating without changes should not change AAL")
|
|
original.Credentials[identity.CredentialsTypeTOTP] = identity.Credentials{
|
|
Type: identity.CredentialsTypeTOTP,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String)
|
|
})
|
|
|
|
t.Run("case=should set AAL to 2 if only TOTP is set", func(t *testing.T) {
|
|
email := uuid.Must(uuid.NewV4()).String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
original.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeTOTP: {
|
|
Type: identity.CredentialsTypeTOTP,
|
|
Identifiers: []string{email},
|
|
Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`),
|
|
},
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
assert.True(t, original.InternalAvailableAAL.Valid)
|
|
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, original.InternalAvailableAAL.String)
|
|
})
|
|
|
|
t.Run("case=should not update protected traits without option", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits("email-update-1@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
original.Traits = newTraits("email-update-2@ory.sh", "")
|
|
err := reg.IdentityManager().Update(t.Context(), original)
|
|
require.Error(t, err)
|
|
assert.Equal(t, identity.ErrProtectedFieldModified(), errors.Cause(err))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
// As UpdateTraits takes only the ID as a parameter it cannot update the identity in place.
|
|
// That is why we only check the identity in the store.
|
|
checkExtensionFields(fromStore, "email-update-1@ory.sh")(t)
|
|
})
|
|
|
|
t.Run("case=should update unprotected traits with multiple credential identifiers", func(t *testing.T) {
|
|
original := identity.NewIdentity("extension")
|
|
original.Traits = identity.Traits(`{"email": "email-update-ewisdfuja@ory.sh", "names": ["username1", "username2"], "age": 30}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
assert.Len(t, original.Credentials[identity.CredentialsTypePassword].Identifiers, 3)
|
|
|
|
original.Traits = identity.Traits(`{"email": "email-update-ewisdfuja@ory.sh", "names": ["username1", "username2"], "age": 31}`)
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(original.Traits), string(fromStore.Traits))
|
|
})
|
|
|
|
t.Run("case=should update unprotected traits with verified user", func(t *testing.T) {
|
|
email := x.NewUUID().String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "initial")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
// mock successful verification process
|
|
addr := original.VerifiableAddresses[0]
|
|
addr.Verified = true
|
|
addr.VerifiedAt = new(sqlxx.NullTime(time.Now().UTC()))
|
|
require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(t.Context(), &addr))
|
|
|
|
// reload to properly set the verified address
|
|
var err error
|
|
original, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
|
|
original.Traits = newTraits(email, "updated")
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(original.Traits), string(fromStore.Traits))
|
|
})
|
|
|
|
t.Run("case=changing recovery address removes it from the store", func(t *testing.T) {
|
|
originalEmail := x.NewUUID().String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(originalEmail, "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
checkExtensionFields(fromStore, originalEmail)(t)
|
|
|
|
newEmail := x.NewUUID().String() + "@ory.sh"
|
|
original.Traits = newTraits(newEmail, "")
|
|
require.NoError(t, reg.IdentityManager().Update(t.Context(), original, identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
fromStore, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
checkExtensionFields(fromStore, newEmail)(t)
|
|
|
|
recoveryAddresses, err := reg.PrivilegedIdentityPool().ListRecoveryAddresses(t.Context(), 0, 500)
|
|
require.NoError(t, err)
|
|
|
|
var foundRecoveryAddress bool
|
|
for _, a := range recoveryAddresses {
|
|
assert.NotEqual(t, a.Value, originalEmail)
|
|
if a.Value == newEmail {
|
|
foundRecoveryAddress = true
|
|
}
|
|
}
|
|
require.True(t, foundRecoveryAddress)
|
|
|
|
verifiableAddresses, err := reg.PrivilegedIdentityPool().ListVerifiableAddresses(t.Context(), 0, 500)
|
|
require.NoError(t, err)
|
|
var foundVerifiableAddress bool
|
|
for _, a := range verifiableAddresses {
|
|
assert.NotEqual(t, a.Value, originalEmail)
|
|
if a.Value == newEmail {
|
|
foundVerifiableAddress = true
|
|
}
|
|
}
|
|
require.True(t, foundVerifiableAddress)
|
|
})
|
|
})
|
|
|
|
t.Run("method=CountActiveFirstFactorCredentials", func(t *testing.T) {
|
|
id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
count, err := reg.IdentityManager().CountActiveFirstFactorCredentials(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, count)
|
|
|
|
id.Credentials[identity.CredentialsTypePassword] = identity.Credentials{
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{"foo"},
|
|
Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
|
}
|
|
|
|
count, err = reg.IdentityManager().CountActiveFirstFactorCredentials(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, count)
|
|
})
|
|
|
|
t.Run("method=CountActiveMultiFactorCredentials", func(t *testing.T) {
|
|
id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
count, err := reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, count)
|
|
|
|
id.Credentials[identity.CredentialsTypePassword] = identity.Credentials{
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{"foo"},
|
|
Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
|
}
|
|
|
|
count, err = reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, count)
|
|
|
|
id.Credentials[identity.CredentialsTypeWebAuthn] = identity.Credentials{
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Identifiers: []string{"foo"},
|
|
Config: []byte(`{"credentials":[{"is_passwordless":false}]}`),
|
|
}
|
|
|
|
count, err = reg.IdentityManager().CountActiveMultiFactorCredentials(t.Context(), id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, count)
|
|
})
|
|
|
|
t.Run("method=UpdateTraits", func(t *testing.T) {
|
|
t.Run("case=should update protected traits with option", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits("email-updatetraits-1@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
require.NoError(t, reg.IdentityManager().UpdateTraits(
|
|
t.Context(), original.ID, newTraits("email-updatetraits-2@ory.sh", ""),
|
|
identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
// As UpdateTraits takes only the ID as a parameter it cannot update the identity in place.
|
|
// That is why we only check the identity in the store.
|
|
checkExtensionFields(fromStore, "email-updatetraits-2@ory.sh")(t)
|
|
})
|
|
|
|
t.Run("case=should update identity and update extension fields", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
// These should all fail because they modify existing keys
|
|
require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"not-baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`)))
|
|
require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"not-baz@ory.sh","email_recovery":"not-baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "foo"}`)))
|
|
require.Error(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"not-baz@ory.sh","unprotected": "foo"}`)))
|
|
|
|
require.NoError(t, reg.IdentityManager().UpdateTraits(t.Context(), original.ID, identity.Traits(`{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "bar"}`)))
|
|
checkExtensionFieldsForIdentities(t, "baz@ory.sh", original)
|
|
|
|
actual, err := reg.IdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"email":"baz@ory.sh","email_verify":"baz@ory.sh","email_recovery":"baz@ory.sh","email_creds":"baz@ory.sh","unprotected": "bar"}`, string(actual.Traits))
|
|
})
|
|
|
|
t.Run("case=should not update protected traits without option", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits("email-updatetraits-1@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
err := reg.IdentityManager().UpdateTraits(
|
|
t.Context(), original.ID, newTraits("email-updatetraits-2@ory.sh", ""))
|
|
require.Error(t, err)
|
|
assert.Equal(t, identity.ErrProtectedFieldModified(), errors.Cause(err))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
// As UpdateTraits takes only the ID as a parameter it cannot update the identity in place.
|
|
// That is why we only check the identity in the store.
|
|
checkExtensionFields(fromStore, "email-updatetraits-1@ory.sh")(t)
|
|
})
|
|
|
|
t.Run("case=should always update updated_at field", func(t *testing.T) {
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits("email-updatetraits-3@ory.sh", "")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
time.Sleep(time.Millisecond)
|
|
|
|
require.NoError(t, reg.IdentityManager().UpdateTraits(
|
|
t.Context(), original.ID, newTraits("email-updatetraits-4@ory.sh", ""),
|
|
identity.ManagerAllowWriteProtectedTraits))
|
|
|
|
updated, err := reg.IdentityPool().GetIdentity(t.Context(), original.ID, identity.ExpandNothing)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, original.UpdatedAt, updated.UpdatedAt, "UpdatedAt field should be updated")
|
|
})
|
|
|
|
t.Run("case=should update unprotected traits with verified user", func(t *testing.T) {
|
|
email := x.NewUUID().String() + "@ory.sh"
|
|
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
original.Traits = newTraits(email, "initial")
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
|
|
// mock successful verification so VerifiedAt is non-nil
|
|
addr := original.VerifiableAddresses[0]
|
|
addr.Verified = true
|
|
addr.VerifiedAt = new(sqlxx.NullTime(time.Now().UTC()))
|
|
require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(t.Context(), &addr))
|
|
|
|
// UpdateTraits -> SetTraits -> deepcopy.Copy is the path that triggered the bug
|
|
require.NoError(t, reg.IdentityManager().UpdateTraits(
|
|
t.Context(), original.ID, newTraits(email, "updated")))
|
|
|
|
fromStore, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), original.ID)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(newTraits(email, "updated")), string(fromStore.Traits))
|
|
})
|
|
})
|
|
|
|
t.Run("method=RefreshAvailableAAL", func(t *testing.T) {
|
|
var cases []struct {
|
|
Credentials []identity.Credentials `json:"credentials"`
|
|
Description string `json:"description"`
|
|
Expected string `json:"expected"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(refreshAALStubs, &cases))
|
|
|
|
for k, tc := range cases {
|
|
t.Run("case="+tc.Description, func(t *testing.T) {
|
|
email := x.NewUUID().String() + "@ory.sh"
|
|
id := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
id.Traits = identity.Traits(`{"email":"` + email + `"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), id))
|
|
assert.EqualValues(t, identity.NoAuthenticatorAssuranceLevel, id.InternalAvailableAAL.String)
|
|
|
|
for _, c := range tc.Credentials {
|
|
for k := range c.Identifiers {
|
|
switch c.Identifiers[k] {
|
|
case "{email}":
|
|
c.Identifiers[k] = email
|
|
case "{id}":
|
|
c.Identifiers[k] = id.ID.String()
|
|
}
|
|
}
|
|
id.SetCredentials(c.Type, c)
|
|
}
|
|
|
|
// We use the privileged pool here because we don't want to refresh AAL here but in the code below.
|
|
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
|
|
|
|
expand := identity.ExpandNothing
|
|
if k%2 == 1 { // expand every other test case to test if RefreshAvailableAAL behaves correctly
|
|
expand = identity.ExpandCredentials
|
|
}
|
|
|
|
actual, err := reg.IdentityPool().GetIdentity(t.Context(), id.ID, expand)
|
|
require.NoError(t, err)
|
|
require.NoError(t, reg.IdentityManager().RefreshAvailableAAL(t.Context(), actual))
|
|
assert.NotEmpty(t, actual.Credentials)
|
|
assert.EqualValues(t, tc.Expected, actual.InternalAvailableAAL.String)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("method=ConflictingIdentity", func(t *testing.T) {
|
|
conflicOnIdentifier := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
conflicOnIdentifier.Traits = identity.Traits(`{"email":"conflict-on-identifier@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnIdentifier))
|
|
|
|
conflicOnVerifiableAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
conflicOnVerifiableAddress.Traits = identity.Traits(`{"email":"user-va@example.com", "email_verify":"conflict-on-va@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnVerifiableAddress))
|
|
|
|
conflicOnRecoveryAddress := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
conflicOnRecoveryAddress.Traits = identity.Traits(`{"email":"user-ra@example.com", "email_recovery":"conflict-on-ra@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), conflicOnRecoveryAddress))
|
|
|
|
t.Run("case=returns not found if no conflict", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"no-conflict@example.com"}},
|
|
},
|
|
})
|
|
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
|
|
assert.Nil(t, found)
|
|
assert.Empty(t, foundConflictAddress)
|
|
assert.Empty(t, addressType)
|
|
})
|
|
|
|
t.Run("case=conflict on identifier", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnIdentifier.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress)
|
|
assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType)
|
|
})
|
|
|
|
t.Run("case=conflict on verifiable address", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
VerifiableAddresses: []identity.VerifiableAddress{{
|
|
Value: "conflict-on-va@example.com",
|
|
Via: "email",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnVerifiableAddress.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-va@example.com", foundConflictAddress)
|
|
assert.Equal(t, "email", addressType)
|
|
})
|
|
t.Run("case=conflict on recovery address", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
RecoveryAddresses: []identity.RecoveryAddress{{
|
|
Value: "conflict-on-ra@example.com",
|
|
Via: "email",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnRecoveryAddress.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-ra@example.com", foundConflictAddress)
|
|
assert.Equal(t, "email", addressType)
|
|
})
|
|
|
|
t.Run("case=identifier match wins over verifiable and recovery address conflicts", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}},
|
|
},
|
|
VerifiableAddresses: []identity.VerifiableAddress{{
|
|
Value: "conflict-on-va@example.com",
|
|
Via: "email",
|
|
}},
|
|
RecoveryAddresses: []identity.RecoveryAddress{{
|
|
Value: "conflict-on-ra@example.com",
|
|
Via: "email",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnIdentifier.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress)
|
|
assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType)
|
|
})
|
|
|
|
t.Run("case=verifiable address wins over recovery address", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
VerifiableAddresses: []identity.VerifiableAddress{{
|
|
Value: "conflict-on-va@example.com",
|
|
Via: "email",
|
|
}},
|
|
RecoveryAddresses: []identity.RecoveryAddress{{
|
|
Value: "conflict-on-ra@example.com",
|
|
Via: "email",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnVerifiableAddress.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-va@example.com", foundConflictAddress)
|
|
assert.Equal(t, "email", addressType)
|
|
})
|
|
|
|
t.Run("case=credential type order is deterministic", func(t *testing.T) {
|
|
oidcSubject := "google:" + x.NewUUID().String()
|
|
conflictOnOIDC := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
conflictOnOIDC.Traits = identity.Traits(`{"email":"conflict-on-oidc@example.com"}`)
|
|
conflictOnOIDC.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{
|
|
Type: identity.CredentialsTypeOIDC,
|
|
Identifiers: []string{oidcSubject},
|
|
Config: sqlxx.JSONRawMessage(`{"providers":[{"provider":"google"}]}`),
|
|
})
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), conflictOnOIDC))
|
|
|
|
conflictOnPassword := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
|
conflictOnPassword.Traits = identity.Traits(`{"email":"conflict-on-password@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), conflictOnPassword))
|
|
|
|
// Both credentials match a different identity. The lookup must
|
|
// check credential types in sorted order, so "oidc" wins over
|
|
// "password" on every run.
|
|
for range 10 {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypeOIDC: {Identifiers: []string{oidcSubject}},
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-password@example.com"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflictOnOIDC.ID, found.ID)
|
|
assert.Equal(t, oidcSubject, foundConflictAddress)
|
|
assert.EqualValues(t, string(identity.CredentialsTypeOIDC), addressType)
|
|
}
|
|
})
|
|
|
|
t.Run("case=second identifier within one credential type matches", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"no-conflict@example.com", "conflict-on-identifier@example.com"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, conflicOnIdentifier.ID, found.ID)
|
|
assert.Equal(t, "conflict-on-identifier@example.com", foundConflictAddress)
|
|
assert.EqualValues(t, string(identity.CredentialsTypePassword), addressType)
|
|
})
|
|
|
|
t.Run("case=empty identity returns no rows", func(t *testing.T) {
|
|
found, foundConflictAddress, addressType, err := reg.IdentityManager().ConflictingIdentity(t.Context(), &identity.Identity{})
|
|
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
|
|
assert.Nil(t, found)
|
|
assert.Empty(t, foundConflictAddress)
|
|
assert.Empty(t, addressType)
|
|
})
|
|
|
|
t.Run("case=canceled context propagates", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
_, _, _, err := reg.IdentityManager().ConflictingIdentity(ctx, &identity.Identity{
|
|
Credentials: map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {Identifiers: []string{"conflict-on-identifier@example.com"}},
|
|
},
|
|
})
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
assert.NotErrorIs(t, err, sqlcon.ErrNoRows())
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestManagerNoDefaultNamedSchema(t *testing.T) {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{
|
|
config.ViperKeyDefaultIdentitySchemaID: "user_v0",
|
|
config.ViperKeyIdentitySchemas: config.Schemas{
|
|
{ID: "user_v0", URL: "file://./stub/manager.schema.json"},
|
|
},
|
|
config.ViperKeyPublicBaseURL: "https://www.ory.com/",
|
|
}))
|
|
|
|
t.Run("case=should create identity with default schema", func(t *testing.T) {
|
|
stateChangedAt := sqlxx.NullTime(time.Now().UTC())
|
|
original := &identity.Identity{
|
|
SchemaID: "",
|
|
Traits: []byte(identity.Traits(`{"email":"foo@ory.sh"}`)),
|
|
State: identity.StateActive,
|
|
StateChangedAt: &stateChangedAt,
|
|
}
|
|
require.NoError(t, reg.IdentityManager().Create(t.Context(), original))
|
|
})
|
|
}
|
|
|
|
func TestManager_SendVerifiableAddressChangedNotifications(t *testing.T) {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t,
|
|
configx.WithValues(map[string]interface{}{
|
|
config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/",
|
|
config.ViperKeyDefaultIdentitySchemaID: "default",
|
|
}),
|
|
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
|
|
"default": "file://./stub/manager.schema.json",
|
|
})),
|
|
)
|
|
|
|
t.Run("case=queues email and sms for supported targets", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"new@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
targets := []identity.AddressRef{
|
|
{Value: "old@example.com", Via: identity.AddressTypeEmail},
|
|
{Value: "+15551234567", Via: identity.AddressTypeSMS},
|
|
}
|
|
|
|
require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, targets, i))
|
|
|
|
messages, err := reg.CourierPersister().NextMessages(ctx, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, messages, 2)
|
|
|
|
var gotEmail, gotSMS bool
|
|
for _, m := range messages {
|
|
switch m.Type {
|
|
case courier.MessageTypeEmail:
|
|
assert.Equal(t, "old@example.com", m.Recipient)
|
|
gotEmail = true
|
|
case courier.MessageTypeSMS:
|
|
assert.Equal(t, "+15551234567", m.Recipient)
|
|
gotSMS = true
|
|
}
|
|
}
|
|
assert.True(t, gotEmail, "expected an email message")
|
|
assert.True(t, gotSMS, "expected an SMS message")
|
|
})
|
|
|
|
t.Run("case=unsupported Via skipped", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"skip@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, []identity.AddressRef{
|
|
{Value: "fax:+123", Via: "fax"},
|
|
}, i))
|
|
|
|
messages, err := reg.CourierPersister().NextMessages(ctx, 10)
|
|
if err == nil {
|
|
for _, m := range messages {
|
|
assert.NotEqual(t, "fax:+123", m.Recipient, "fax target must not be queued")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("case=empty targets is noop", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"noop@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
require.NoError(t, reg.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, nil, i))
|
|
})
|
|
}
|
|
|
|
func TestManager_SendAuthenticatorKeyAddedNotifications(t *testing.T) {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t,
|
|
configx.WithValues(map[string]interface{}{
|
|
config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/",
|
|
config.ViperKeyDefaultIdentitySchemaID: "default",
|
|
}),
|
|
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
|
|
"default": "file://./stub/manager.schema.json",
|
|
})),
|
|
)
|
|
|
|
t.Run("case=queues email and sms for supported targets", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"key-added@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
targets := []identity.AddressRef{
|
|
{Value: "owner@example.com", Via: identity.AddressTypeEmail},
|
|
{Value: "+15557654321", Via: identity.AddressTypeSMS},
|
|
}
|
|
|
|
require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, targets, i))
|
|
|
|
messages, err := reg.CourierPersister().NextMessages(ctx, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, messages, 2)
|
|
|
|
var gotEmail, gotSMS bool
|
|
for _, m := range messages {
|
|
assert.Equal(t, template.TypeAuthenticatorKeyAdded, m.TemplateType)
|
|
switch m.Type {
|
|
case courier.MessageTypeEmail:
|
|
assert.Equal(t, "owner@example.com", m.Recipient)
|
|
gotEmail = true
|
|
case courier.MessageTypeSMS:
|
|
assert.Equal(t, "+15557654321", m.Recipient)
|
|
gotSMS = true
|
|
}
|
|
}
|
|
assert.True(t, gotEmail, "expected an email message")
|
|
assert.True(t, gotSMS, "expected an SMS message")
|
|
})
|
|
|
|
t.Run("case=unsupported Via skipped", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"skip-key@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, []identity.AddressRef{
|
|
{Value: "fax:+123", Via: "fax"},
|
|
}, i))
|
|
|
|
messages, err := reg.CourierPersister().NextMessages(ctx, 10)
|
|
if err == nil {
|
|
for _, m := range messages {
|
|
assert.NotEqual(t, "fax:+123", m.Recipient, "fax target must not be queued")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("case=empty targets is noop", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
i := identity.NewIdentity("default")
|
|
i.Traits = identity.Traits(`{"email":"noop-key@example.com"}`)
|
|
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
|
|
|
require.NoError(t, reg.IdentityManager().SendAuthenticatorKeyAddedNotifications(ctx, nil, i))
|
|
})
|
|
}
|