diff --git a/identity/test/pool.go b/identity/test/pool.go new file mode 100644 index 0000000..73c793e --- /dev/null +++ b/identity/test/pool.go @@ -0,0 +1,4306 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-faker/faker/v4" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/herodot" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/persistence" + idpersistence "github.com/ory/kratos/persistence/sql/identity" + "github.com/ory/kratos/pkg/testhelpers" + "github.com/ory/kratos/schema" + "github.com/ory/kratos/x" + "github.com/ory/pop/v6" + "github.com/ory/x/assertx" + "github.com/ory/x/crdbx" + "github.com/ory/x/pagination/keysetpagination" + "github.com/ory/x/popx" + "github.com/ory/x/randx" + "github.com/ory/x/sqlcon" + "github.com/ory/x/sqlxx" + "github.com/ory/x/urlx" +) + +// assertContainsValues is a test helper that checks if a slice contains expected values and doesn't contain unexpected values. +func assertContainsValues(t *testing.T, actual []string, shouldContain, shouldNotContain []string) { + t.Helper() + for _, expected := range shouldContain { + assert.Contains(t, actual, expected) + } + for _, notExpected := range shouldNotContain { + assert.NotContains(t, actual, notExpected) + } +} + +// poolSchemaSet is the identity-schema set the TestPool contract exercises. +// +// Only ID and RawURL are set on each schema.Schema: the config carries RawURL +// (see PoolConfigValues), and SchemaURL() derives the public URL from ID, so +// schema.Schema.URL is never read here. +type poolSchemaSet struct { + expand, def, alt, phoneEmail schema.Schema + publicBaseURL *url.URL +} + +func newPoolSchemaSet() poolSchemaSet { + return poolSchemaSet{ + publicBaseURL: urlx.ParseOrPanic("http://example.com"), + expand: schema.Schema{ + ID: "expandSchema", + RawURL: "file://./stub/expand.schema.json", + }, + def: schema.Schema{ + ID: config.DefaultIdentityTraitsSchemaID, + RawURL: "file://./stub/identity.schema.json", + }, + alt: schema.Schema{ + ID: "altSchema", + RawURL: "file://./stub/identity-2.schema.json", + }, + phoneEmail: schema.Schema{ + ID: "phoneIdentifier", + RawURL: "file://./stub/phone.schema.json", + }, + } +} + +// PoolConfigValues returns the configuration the TestPool contract requires. +// +// Prefer passing these to the registry as base config (configx.WithValues) over +// attaching them to the context with contextx.WithConfigValues. A context +// overlay makes TestConfigProvider.Config rebuild the whole koanf tree — schema +// validation and a full re-flatten — on every config read underneath it, and +// TestPool reads config from 187 subtests. +func PoolConfigValues() map[string]any { + s := newPoolSchemaSet() + return map[string]any{ + config.ViperKeyPublicBaseURL: s.publicBaseURL.String(), + // Set explicitly so callers do not also need the suite-wide + // testhelpers.WithDefaultIdentitySchema overlay — that overlay replaces + // the schema list below with a single "default" entry. + config.ViperKeyDefaultIdentitySchemaID: s.def.ID, + config.ViperKeyIdentitySchemas: []config.Schema{ + {ID: s.alt.ID, URL: s.alt.RawURL}, + {ID: s.def.ID, URL: s.def.RawURL}, + {ID: s.expand.ID, URL: s.expand.RawURL}, + // The multiple_emails schema is only registered so config accepts the + // ID; the contract never reads its traits, so it reuses altSchema's file. + {ID: "multiple_emails", URL: s.alt.RawURL}, + {ID: s.phoneEmail.ID, URL: s.phoneEmail.RawURL}, + }, + } +} + +// TestPool runs the identity persister contract. The caller must have the values +// from PoolConfigValues in scope, ideally baked into the registry's base config. +func TestPool(ctx context.Context, p persistence.Persister, m *identity.Manager, dbname string) func(t *testing.T) { + return func(t *testing.T) { + nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p) + + s := newPoolSchemaSet() + exampleServerURL := s.publicBaseURL + expandSchema, defaultSchema, altSchema := s.expand, s.def, s.alt + phoneEmailSchema := s.phoneEmail + + // Fail fast (rather than deep inside case=expand) when the caller did not + // put PoolConfigValues into scope: without the schemas registered, + // ValidateIdentity returns an opaque schema-lookup error. + probe := identity.NewIdentity(expandSchema.ID) + probe.Traits = identity.Traits(`{"email":"probe@ory.sh","name":"probe"}`) + require.NoError(t, m.ValidateIdentity(ctx, probe, new(identity.ManagerOptions)), + "TestPool requires the schemas from identity.PoolConfigValues in scope") + + t.Run("case=expand", func(t *testing.T) { + require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ?", nid).Exec()) + t.Cleanup(func() { + require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ?", nid).Exec()) + }) + + expected := identity.NewIdentity(expandSchema.ID) + expected.Traits = identity.Traits(`{"email":"` + uuid.Must(uuid.NewV4()).String() + "@ory.sh" + `","name":"john doe"}`) + require.NoError(t, m.ValidateIdentity(ctx, expected, new(identity.ManagerOptions))) + require.NoError(t, p.CreateIdentity(ctx, expected)) + require.NoError(t, identity.UpgradeCredentials(expected)) + + assert.NotEmpty(t, expected.RecoveryAddresses) + assert.NotEmpty(t, expected.VerifiableAddresses) + assert.NotEmpty(t, expected.Credentials) + assert.NotEqual(t, uuid.Nil, expected.RecoveryAddresses[0].ID) + assert.NotEqual(t, uuid.Nil, expected.VerifiableAddresses[0].ID) + + runner := func(t *testing.T, expand sqlxx.Expandables, cb func(*testing.T, *identity.Identity)) { + assertion := func(t *testing.T, actual *identity.Identity) { + assertx.EqualAsJSONExcept(t, expected, actual, []string{ + "verifiable_addresses", "recovery_addresses", "updated_at", "created_at", "credentials", "state_changed_at", + // region is hydrated by the multi-region persister from + // the row's crdb_region, but the OSS-side `expected` + // snapshot is built from the in-memory Identity which + // has Region empty when the test does not set it. + "region", + }) + cb(t, actual) + } + + t.Run("find", func(t *testing.T) { + actual, err := p.GetIdentity(ctx, expected.ID, expand) + require.NoError(t, err) + assertion(t, actual) + }) + + t.Run("list/page-pagination", func(t *testing.T) { + actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: expand, PagePagination: &x.Page{Page: 0, ItemsPerPage: 10}}) + require.NoError(t, err) + require.Len(t, actual, 1) + assertion(t, &actual[0]) + }) + + t.Run("list/token-pagination", func(t *testing.T) { + actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: expand, KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(10)}}) + require.NoError(t, err) + require.Len(t, actual, 1) + require.True(t, next.IsLast()) + assertion(t, &actual[0]) + }) + } + + t.Run("expand=nothing", func(t *testing.T) { + runner(t, identity.ExpandNothing, func(t *testing.T, actual *identity.Identity) { + assert.Empty(t, actual.RecoveryAddresses) + assert.Empty(t, actual.VerifiableAddresses) + assert.Empty(t, actual.Credentials) + }) + }) + + t.Run("expand=credentials", func(t *testing.T) { + runner(t, identity.ExpandCredentials, func(t *testing.T, actual *identity.Identity) { + assert.Empty(t, actual.RecoveryAddresses) + assert.Empty(t, actual.VerifiableAddresses) + + require.Len(t, actual.Credentials, 2) + + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) + }) + }) + + t.Run("expand=recovery address", func(t *testing.T) { + runner(t, sqlxx.Expandables{identity.ExpandFieldRecoveryAddresses}, func(t *testing.T, actual *identity.Identity) { + assert.Empty(t, actual.Credentials) + assert.Empty(t, actual.VerifiableAddresses) + + require.Len(t, actual.RecoveryAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("expand=verification address", func(t *testing.T) { + runner(t, sqlxx.Expandables{identity.ExpandFieldVerifiableAddresses}, func(t *testing.T, actual *identity.Identity) { + assert.Empty(t, actual.Credentials) + assert.Empty(t, actual.RecoveryAddresses) + + require.Len(t, actual.VerifiableAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("expand=default", func(t *testing.T) { + runner(t, identity.ExpandDefault, func(t *testing.T, actual *identity.Identity) { + assert.Empty(t, actual.Credentials) + + require.Len(t, actual.RecoveryAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) + + require.Len(t, actual.VerifiableAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("expand=everything", func(t *testing.T) { + runner(t, identity.ExpandEverything, func(t *testing.T, actual *identity.Identity) { + require.Len(t, actual.Credentials, 2) + + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) + + require.Len(t, actual.RecoveryAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) + + require.Len(t, actual.VerifiableAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("expand=load", func(t *testing.T) { + runner(t, identity.ExpandNothing, func(t *testing.T, actual *identity.Identity) { + require.NoError(t, p.HydrateIdentityAssociations(ctx, actual, identity.ExpandEverything)) + + require.Len(t, actual.Credentials, 2) + + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) + + require.Len(t, actual.RecoveryAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) + + require.Len(t, actual.VerifiableAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("confidential", func(t *testing.T) { + // confidential is like expand=all + actual, err := p.GetIdentityConfidential(ctx, expected.ID) + require.NoError(t, err) + assertx.EqualAsJSONExcept(t, expected, actual, []string{ + "verifiable_addresses", "recovery_addresses", "updated_at", "created_at", "credentials", "state_changed_at", + // region is hydrated by the multi-region persister; the + // in-memory expected snapshot has Region empty. + "region", + }) + require.Len(t, actual.Credentials, 2) + + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypePassword], actual.Credentials[identity.CredentialsTypePassword], []string{"updated_at", "created_at"}) + assertx.EqualAsJSONExcept(t, expected.Credentials[identity.CredentialsTypeWebAuthn], actual.Credentials[identity.CredentialsTypeWebAuthn], []string{"updated_at", "created_at"}) + + require.Len(t, actual.RecoveryAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.RecoveryAddresses, actual.RecoveryAddresses, []string{"0.updated_at", "0.created_at"}) + + require.Len(t, actual.VerifiableAddresses, 1) + assertx.EqualAsJSONExcept(t, expected.VerifiableAddresses, actual.VerifiableAddresses, []string{"0.updated_at", "0.created_at"}) + }) + }) + + t.Run("case=phone number backward compatibility", func(t *testing.T) { + // Clean up identities after test to prevent pollution of subsequent tests + t.Cleanup(func() { + require.NoError(t, p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE nid = ? AND schema_id = ?", nid, phoneEmailSchema.ID).Exec()) + }) + + // Test backward compatibility: simulating the upgrade path for legacy phone credentials + nonNormalizedPhone := "+49 176 671 11 638" + normalizedPhone := "+4917667111638" + + t.Run("new identities get normalized credentials from the start", func(t *testing.T) { + // Create identity through validation (simulates user registration) + newIdentity := identity.NewIdentity(phoneEmailSchema.ID) + newIdentity.Traits = identity.Traits(`{"phone":"` + nonNormalizedPhone + `","name":"john doe"}`) + require.NoError(t, m.ValidateIdentity(ctx, newIdentity, new(identity.ManagerOptions))) + require.NoError(t, p.CreateIdentity(ctx, newIdentity)) + + // Verify it was created with normalized credentials from the start + created, err := p.GetIdentityConfidential(ctx, newIdentity.ID) + require.NoError(t, err) + + // Should have normalized credentials + if cred, ok := created.Credentials[identity.CredentialsTypePassword]; ok { + require.Contains(t, cred.Identifiers, normalizedPhone, "New identity should have normalized phone") + } + + // Should be findable by normalized phone + found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, newIdentity.ID, found.ID) + require.NotNil(t, cred) + }) + + t.Run("legacy identities are upgraded in memory when loaded", func(t *testing.T) { + // Use different phone numbers to avoid conflict with first sub-test + legacyNonNormalizedPhone := "+49 30 1234 5678" + legacyNormalizedPhone := "+493012345678" + + // Simulate legacy data: create identity with version 0 credential using raw SQL + // This is necessary because CreateIdentity now normalizes identifiers at persistence layer + var credTypes []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&credTypes)) + var passwordCredType identity.CredentialsTypeTable + for _, ct := range credTypes { + if ct.Name == identity.CredentialsTypePassword { + passwordCredType = ct + break + } + } + + legacyID := x.NewUUID() + credID := x.NewUUID() + identifierID := x.NewUUID() + now := time.Now() + traits := sqlxx.JSONRawMessage(`{"phone":"` + legacyNonNormalizedPhone + `","name":"jane doe"}`) + + // Cleanup + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() + }) + + // Insert legacy identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + legacyID, nid, phoneEmailSchema.ID, traits, now, now, identity.StateActive, + ).Exec()) + + // Insert version 0 credential + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"hashed_password":"$2a$10$..."}`), 0, + ).Exec()) + + // Insert NON-NORMALIZED identifier only + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID, legacyID, credID, nid, legacyNonNormalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Load the identity - this triggers UpgradeCredentials in memory + loaded, err := p.GetIdentityConfidential(ctx, legacyID) + require.NoError(t, err) + require.Equal(t, 1, loaded.Credentials[identity.CredentialsTypePassword].Version, "Should be upgraded in memory") + + // In-memory upgrade adds normalized identifier + identifiers := loaded.Credentials[identity.CredentialsTypePassword].Identifiers + require.Len(t, identifiers, 1, "Should have single identifier in memory") + require.Contains(t, identifiers, legacyNormalizedPhone) + + // Can find by non-normalized phone (what's in DB) + found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNonNormalizedPhone) + require.NoError(t, err, "Should find by non-normalized phone") + require.NotNil(t, found) + require.Equal(t, legacyID, found.ID) + require.NotNil(t, cred) + }) + + t.Run("post-migration: legacy data normalized in DB", func(t *testing.T) { + // Simulates the state after running `kratos migrate normalize-phone-numbers`: + // Legacy data has been normalized in the DB, and all lookups should work with any input format. + legacyNonNormalizedPhone := "+49 176 12345678" + legacyNormalizedPhone := "+4917612345678" + + var credTypes []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&credTypes)) + var passwordCredType identity.CredentialsTypeTable + for _, ct := range credTypes { + if ct.Name == identity.CredentialsTypePassword { + passwordCredType = ct + break + } + } + + legacyID := x.NewUUID() + credID := x.NewUUID() + identifierID := x.NewUUID() + now := time.Now() + traits := sqlxx.JSONRawMessage(`{"phone":"` + legacyNonNormalizedPhone + `","name":"migrated user"}`) + + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() + }) + + // Step 1: Insert legacy identity with non-normalized phone (pre-migration state) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + legacyID, nid, phoneEmailSchema.ID, traits, now, now, identity.StateActive, + ).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"hashed_password":"$2a$10$..."}`), 0, + ).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID, legacyID, credID, nid, legacyNonNormalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Step 2: Simulate migration — normalize the identifier in DB (what the CLI tool does) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "UPDATE identity_credential_identifiers SET identifier = ? WHERE id = ?", + legacyNormalizedPhone, identifierID, + ).Exec()) + + // Step 3: Verify lookups work with both formats after migration + // Find by E.164 (normalized) — should match the migrated DB value + found, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNormalizedPhone) + require.NoError(t, err, "should find migrated identity by E.164 phone") + require.Equal(t, legacyID, found.ID) + require.NotNil(t, cred) + + // Find by original non-normalized format — IN query normalizes input, matches migrated DB value + found2, cred2, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, legacyNonNormalizedPhone) + require.NoError(t, err, "should find migrated identity by non-normalized phone (IN query normalizes)") + require.Equal(t, legacyID, found2.ID) + require.NotNil(t, cred2) + + // Find by yet another format variant — also normalizes to same E.164 + found3, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, "+49 17612345678") + require.NoError(t, err, "should find migrated identity by different format variant") + require.Equal(t, legacyID, found3.ID) + + // Also test FindIdentityByCredentialIdentifier + found4, err := p.FindIdentityByCredentialIdentifier(ctx, legacyNormalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find migrated identity by E.164 via FindIdentityByCredentialIdentifier") + require.Equal(t, legacyID, found4.ID) + + found5, err := p.FindIdentityByCredentialIdentifier(ctx, legacyNonNormalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find migrated identity by non-normalized via FindIdentityByCredentialIdentifier") + require.Equal(t, legacyID, found5.ID) + }) + }) + + var createdIDs []uuid.UUID + passwordIdentity := func(schemaID string, credentialsID string) *identity.Identity { + i := identity.NewIdentity(schemaID) + i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, Identifiers: []string{credentialsID}, + Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`), + }) + return i + } + + oidcIdentity := func(schemaID string, credentialsID string) *identity.Identity { + i := identity.NewIdentity(schemaID) + i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{credentialsID}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + return i + } + + assertEqual := func(t *testing.T, expected, actual *identity.Identity) { + assert.Empty(t, actual.Credentials) + require.Equal(t, expected.Traits, actual.Traits) + require.Equal(t, expected.ID, actual.ID) + } + + t.Run("case=should create and set missing ID", func(t *testing.T) { + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + i.ID = uuid.Nil + require.NoError(t, p.CreateIdentity(ctx, i)) + assert.NotEqual(t, uuid.Nil, i.ID) + assert.Equal(t, nid, i.NID) + createdIDs = append(createdIDs, i.ID) + + count, err := p.CountIdentities(ctx) + require.NoError(t, err) + assert.EqualValues(t, int64(1), count) + + t.Run("different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + count, err := p.CountIdentities(ctx) + require.NoError(t, err) + assert.EqualValues(t, int64(0), count) + }) + }) + + t.Run("case=create with default values", func(t *testing.T) { + expected := passwordIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) + require.NoError(t, err) + + assert.Equal(t, expected.ID, actual.ID) + assert.Equal(t, config.DefaultIdentityTraitsSchemaID, actual.SchemaID) + assert.Equal(t, defaultSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) + assertEqual(t, expected, actual) + + count, err := p.CountIdentities(ctx) + require.NoError(t, err) + assert.EqualValues(t, 2, count) + + t.Run("different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + count, err := p.CountIdentities(ctx) + require.NoError(t, err) + assert.EqualValues(t, int64(0), count) + }) + }) + + t.Run("case=should set external ID", func(t *testing.T) { + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + i.ID = uuid.Nil + externalID := sqlxx.NullString("external-id-" + randx.MustString(10, randx.AlphaNum)) + i.ExternalID = externalID + require.NoError(t, p.CreateIdentity(ctx, i)) + assert.NotEqual(t, uuid.Nil, i.ID) + assert.Equal(t, nid, i.NID) + assert.Equal(t, externalID, i.ExternalID) + createdIDs = append(createdIDs, i.ID) + + t.Run("find by external ID", func(t *testing.T) { + i2, err := p.FindIdentityByExternalID(ctx, externalID.String(), identity.ExpandEverything) + require.NoError(t, err) + assert.Equal(t, i.ID, i2.ID) + }) + + t.Run("must be unique", func(t *testing.T) { + i2 := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i2.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + i2.ExternalID = externalID + + err := new(herodot.DefaultError) + require.ErrorAs(t, p.CreateIdentity(ctx, i2), &err) + assert.Equal(t, http.StatusConflict, err.CodeField) + }) + }) + + t.Run("case=create with null AAL", func(t *testing.T) { + expected := passwordIdentity("", "id-"+uuid.Must(uuid.NewV4()).String()) + expected.InternalAvailableAAL.Valid = false + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) + require.NoError(t, err) + + assert.False(t, actual.InternalAvailableAAL.Valid) + }) + + t.Run("suite=create multiple identities", func(t *testing.T) { + t.Run("create multiple identities", func(t *testing.T) { + identities := make([]*identity.Identity, 100) + for i := range identities { + identities[i] = NewTestIdentity(4, "persister-create-multiple", i) + } + require.NoError(t, p.CreateIdentities(ctx, identities)) + createdAt := time.Now().UTC() + + for _, id := range identities { + idFromDB, err := p.GetIdentity(ctx, id.ID, identity.ExpandEverything) + require.NoError(t, err) + + credFromDB := idFromDB.Credentials[identity.CredentialsTypePassword] + assert.Equal(t, id.ID, idFromDB.ID) + assert.Equal(t, id.SchemaID, idFromDB.SchemaID) + assert.Equal(t, id.SchemaURL, idFromDB.SchemaURL) + assert.Equal(t, id.State, idFromDB.State) + + // We test that the values are plausible in the handler test already. + assert.Equal(t, len(id.VerifiableAddresses), len(idFromDB.VerifiableAddresses)) + assert.Equal(t, len(id.RecoveryAddresses), len(idFromDB.RecoveryAddresses)) + + assert.Equal(t, id.Credentials["password"].Identifiers, credFromDB.Identifiers) + assert.WithinDuration(t, createdAt, credFromDB.CreatedAt, time.Minute) + assert.WithinDuration(t, createdAt, credFromDB.UpdatedAt, time.Minute) + // because of mysql precision + assert.WithinDuration(t, id.CreatedAt, idFromDB.CreatedAt, time.Second) + assert.WithinDuration(t, id.UpdatedAt, idFromDB.UpdatedAt, time.Second) + + require.NoError(t, p.DeleteIdentity(ctx, id.ID)) + } + }) + + t.Run("create exactly the non-conflicting ones", func(t *testing.T) { + identities := make([]*identity.Identity, 100) + for i := range identities { + identities[i] = NewTestIdentity(4, "persister-create-multiple-2", i%60) + } + err := p.CreateIdentities(ctx, identities) + if dbname == "mysql" { + // partial inserts are not supported on mysql + assert.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) + return + } + createdAt := time.Now().UTC() + + errWithCtx := new(identity.CreateIdentitiesError) + require.ErrorAsf(t, err, &errWithCtx, "%#v", err) + + for _, id := range identities[:60] { + require.NotZero(t, id.ID) + + idFromDB, err := p.GetIdentity(ctx, id.ID, identity.ExpandEverything) + require.NoError(t, err) + + credFromDB := idFromDB.Credentials[identity.CredentialsTypePassword] + assert.Equal(t, id.ID, idFromDB.ID) + assert.Equal(t, id.SchemaID, idFromDB.SchemaID) + assert.Equal(t, id.SchemaURL, idFromDB.SchemaURL) + assert.Equal(t, id.State, idFromDB.State) + + // We test that the values are plausible in the handler test already. + assert.Equal(t, len(id.VerifiableAddresses), len(idFromDB.VerifiableAddresses)) + assert.Equal(t, len(id.RecoveryAddresses), len(idFromDB.RecoveryAddresses)) + + assert.Equal(t, id.Credentials["password"].Identifiers, credFromDB.Identifiers) + assert.WithinDuration(t, createdAt, credFromDB.CreatedAt, time.Minute) + assert.WithinDuration(t, createdAt, credFromDB.UpdatedAt, time.Minute) + // because of mysql precision + assert.WithinDuration(t, id.CreatedAt, idFromDB.CreatedAt, time.Second) + assert.WithinDuration(t, id.UpdatedAt, idFromDB.UpdatedAt, time.Second) + + require.NoError(t, p.DeleteIdentity(ctx, id.ID)) + } + + for _, id := range identities[60:] { + failed := errWithCtx.Find(id) + assert.NotNil(t, failed) + } + }) + + t.Run("case=conflict only on credential identifier marks the identity as failed", func(t *testing.T) { + // Regression test: OIDC identities carry no verifiable or recovery + // addresses, so a duplicate conflicts solely on the credential + // identifier. CreateIdentities must map that conflict back to the + // owning identity, mark it failed, and not leave a partially + // persisted identity behind. + if dbname == "mysql" { + // Partial inserts are not supported on mysql. + t.Skip() + } + + oidcID := randx.MustString(16, randx.AlphaLowerNum) + initial := oidcIdentity("", oidcID) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + unique := oidcIdentity("", randx.MustString(16, randx.AlphaLowerNum)) + conflicting := oidcIdentity("", oidcID) + batch := []*identity.Identity{unique, conflicting} + + err := p.CreateIdentities(ctx, batch) + errWithCtx := new(identity.CreateIdentitiesError) + require.ErrorAsf(t, err, &errWithCtx, "%#v", err) + + // The conflicting identity must be reported as failed and must not + // exist in the database. + require.NotNil(t, errWithCtx.Find(conflicting)) + _, err = p.GetIdentity(ctx, conflicting.ID, identity.ExpandNothing) + require.Error(t, err) + + // The non-conflicting identity must succeed and be persisted. + require.Nil(t, errWithCtx.Find(unique)) + _, err = p.GetIdentity(ctx, unique.ID, identity.ExpandNothing) + require.NoError(t, err) + createdIDs = append(createdIDs, unique.ID) + }) + + t.Run("case=transaction retry re-runs the batch create with the ids from the first attempt", func(t *testing.T) { + // A transaction retry discards the first attempt's writes and runs + // the closure passed to the Transaction helper again. The ids + // assigned during the first attempt (generated by the database on + // CockroachDB, by Go elsewhere) stay on the in-memory structs, so + // the second attempt inserts the same identities with pre-assigned + // ids. The result must be indistinguishable from a first-try + // success, in particular the child-table rows must be linked to + // the right identities. + // + // CreateIdentities joins a surrounding transaction, so the retry + // re-runs it as a whole — exactly what happens when the driver + // retries the transaction of a plain CreateIdentities call. + identities := make([]*identity.Identity, 4) + for i := range identities { + identities[i] = NewTestIdentity(2, "retry-batch", i) + } + + attempt := 0 + idsSeen := make(map[int][]uuid.UUID) + require.NoError(t, p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error { + attempt++ + if err := p.CreateIdentities(ctx, identities); err != nil { + return err + } + for _, ident := range identities { + if !assert.NotEqual(t, uuid.Nil, ident.ID) { + return errors.New("identity id was not assigned") + } + idsSeen[attempt] = append(idsSeen[attempt], ident.ID) + } + if attempt == 1 { + // Force a genuine retryable error so the Transaction + // helper rolls this attempt back and re-runs the closure. + if dbname == "cockroach" { + return tx.RawQuery("SELECT crdb_internal.force_retry('1h'::INTERVAL)").Exec() + } + return sqlcon.ErrConcurrentUpdate() + } + return nil + })) + require.Equal(t, 2, attempt, "the transaction closure must have run twice") + assert.Equal(t, idsSeen[1], idsSeen[2], "the retry must reuse the ids assigned on the first attempt") + + for _, ident := range identities { + fromDB, err := p.GetIdentity(ctx, ident.ID, identity.ExpandEverything) + require.NoError(t, err) + assert.Equal(t, ident.ID, fromDB.ID) + assert.Equal(t, + ident.Credentials[identity.CredentialsTypePassword].Identifiers, + fromDB.Credentials[identity.CredentialsTypePassword].Identifiers, + "the credentials must belong to the identity that carried them in the input batch") + assert.Len(t, fromDB.VerifiableAddresses, len(ident.VerifiableAddresses)) + assert.Len(t, fromDB.RecoveryAddresses, len(ident.RecoveryAddresses)) + + // Clean up so the later list assertions, which expect every + // remaining identity to be tracked in createdIDs and to fit + // in a single page, are unaffected. + require.NoError(t, p.DeleteIdentity(ctx, ident.ID)) + } + }) + + t.Run("case=transaction retry keeps partial-conflict semantics", func(t *testing.T) { + if dbname == "mysql" { + // Partial inserts are not supported on mysql. + t.Skip() + } + + // Same retry scenario, but on the partial-inserts path: one + // identity in the batch conflicts on external_id with a + // pre-existing identity. The retry re-runs the batch with the ids + // assigned on the first attempt; the conflict must still be + // attributed to the same input identity and the non-conflicting + // one must be persisted. + existing := NewTestIdentity(1, "retry-partial-existing", 0) + existing.ExternalID = sqlxx.NullString("retry-partial-ext-0") + require.NoError(t, p.CreateIdentity(ctx, existing)) + + unique := NewTestIdentity(1, "retry-partial-unique", 1) + conflicting := NewTestIdentity(1, "retry-partial-conflict", 2) + conflicting.ExternalID = existing.ExternalID + batch := []*identity.Identity{unique, conflicting} + + attempt := 0 + var createErr error + require.NoError(t, p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error { + attempt++ + createErr = p.CreateIdentities(ctx, batch) + if _, ok := errors.AsType[*identity.CreateIdentitiesError](createErr); createErr != nil && !ok { + return createErr + } + if attempt == 1 { + if dbname == "cockroach" { + return tx.RawQuery("SELECT crdb_internal.force_retry('1h'::INTERVAL)").Exec() + } + return sqlcon.ErrConcurrentUpdate() + } + return nil + })) + require.Equal(t, 2, attempt, "the transaction closure must have run twice") + + errWithCtx := new(identity.CreateIdentitiesError) + require.ErrorAsf(t, createErr, &errWithCtx, "%#v", createErr) + assert.NotNil(t, errWithCtx.Find(conflicting), "the conflict must be attributed to the conflicting input identity") + assert.Nil(t, errWithCtx.Find(unique)) + + fromDB, err := p.GetIdentity(ctx, unique.ID, identity.ExpandNothing) + require.NoError(t, err) + assert.Equal(t, unique.ID, fromDB.ID) + + _, err = p.GetIdentity(ctx, conflicting.ID, identity.ExpandNothing) + require.Error(t, err, "the conflicting identity must not be persisted") + + // Clean up so the later list assertions, which expect every + // remaining identity to be tracked in createdIDs and to fit in + // a single page, are unaffected. + require.NoError(t, p.DeleteIdentity(ctx, unique.ID)) + require.NoError(t, p.DeleteIdentity(ctx, existing.ID)) + }) + }) + + t.Run("case=external_id conflict in batch create returns partial error", func(t *testing.T) { + // Regression test for https://github.com/ory-corp/cloud/issues/10580 + // When all identities in a batch conflict on external_id, CreateIdentities + // should return a CreateIdentitiesError, not a raw SQL error. + // On Postgres, the bug causes: ERROR: syntax error at or near ")" (SQLSTATE 42601) + // because DeleteIdentities is called with an empty ID slice, generating IN (). + + // Step 1: Create two identities with external_ids. + first := make([]*identity.Identity, 2) + for i := range first { + first[i] = NewTestIdentity(1, "ext-id-conflict-first", i) + first[i].ExternalID = sqlxx.NullString(fmt.Sprintf("ext-conflict-pool-%d", i)) + } + require.NoError(t, p.CreateIdentities(ctx, first)) + for _, id := range first { + createdIDs = append(createdIDs, id.ID) + } + + // Step 2: Create new identities with different traits but same external_ids. + second := make([]*identity.Identity, 2) + for i := range second { + second[i] = NewTestIdentity(1, "ext-id-conflict-second", i) + second[i].ExternalID = sqlxx.NullString(fmt.Sprintf("ext-conflict-pool-%d", i)) + } + err := p.CreateIdentities(ctx, second) + if dbname == "mysql" { + assert.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) + return + } + errWithCtx := new(identity.CreateIdentitiesError) + require.ErrorAsf(t, err, &errWithCtx, "expected CreateIdentitiesError, got: %#v", err) + for _, id := range second { + assert.NotNil(t, errWithCtx.Find(id), "expected identity %s to be in the error", id.ID) + } + }) + + t.Run("case=should error when the identity ID does not exist", func(t *testing.T) { + _, err := p.GetIdentity(ctx, uuid.UUID{}, identity.ExpandNothing) + require.Error(t, err) + + _, err = p.GetIdentity(ctx, x.NewUUID(), identity.ExpandNothing) + require.Error(t, err) + + _, err = p.GetIdentityConfidential(ctx, x.NewUUID()) + require.Error(t, err) + }) + + t.Run("case=run migrations when fetching credentials", func(t *testing.T) { + expected := func(schemaID string, credentialsID string) *identity.Identity { + i := identity.NewIdentity(schemaID) + i.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ + Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{credentialsID}, + Config: sqlxx.JSONRawMessage(`{"credentials":[{}]}`), + }) + return i + }(altSchema.ID, "webauthn") + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.GetIdentityConfidential(ctx, expected.ID) + require.NoError(t, err) + c := actual.GetCredentialsOr(identity.CredentialsTypeWebAuthn, &identity.Credentials{}) + assert.True(t, gjson.GetBytes(c.Config, "credentials.0.is_passwordless").Exists()) + assert.Equal(t, base64.StdEncoding.EncodeToString(expected.ID[:]), gjson.GetBytes(c.Config, "user_handle").String()) + }) + + t.Run("case=create and keep set values", func(t *testing.T) { + expected := passwordIdentity(altSchema.ID, x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.GetIdentity(ctx, expected.ID, identity.ExpandDefault) + require.NoError(t, err) + assert.Equal(t, altSchema.ID, actual.SchemaID) + assert.Equal(t, altSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) + assertEqual(t, expected, actual) + + actual, err = p.GetIdentityConfidential(ctx, expected.ID) + require.NoError(t, err) + require.Equal(t, expected.Traits, actual.Traits) + require.Equal(t, expected.ID, actual.ID) + + assert.NotEmpty(t, actual.Credentials) + assert.NotEmpty(t, expected.Credentials) + + for m, expected := range expected.Credentials { + assert.Equal(t, expected.ID, actual.Credentials[m].ID) + assert.JSONEq(t, string(expected.Config), string(actual.Credentials[m].Config)) + assert.Equal(t, expected.Identifiers, actual.Credentials[m].Identifiers) + assert.Equal(t, expected.Type, actual.Credentials[m].Type) + } + + t.Run("different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + _, err = p.GetIdentityConfidential(ctx, expected.ID) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=fail on duplicate credential identifiers if type is password", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@bar.com" + initial := passwordIdentity("", email) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + for _, transform := range []func(string) string{ + strings.ToLower, + func(s string) string { return s[:1] + strings.ToUpper(s[1:2]) + s[2:] }, + strings.ToUpper, + func(s string) string { left, right, _ := strings.Cut(s, "@"); return left + "@" + strings.Title(right) }, + // Invisible Unicode characters (category Cf) must not let an + // attacker register a second visually-identical identifier. + func(s string) string { return "\u200B" + s }, // zero-width space prefix + func(s string) string { return s[:1] + "\u200C" + s[1:] }, + func(s string) string { return s[:1] + "\u200D" + s[1:] }, + func(s string) string { return s[:1] + "\u00AD" + s[1:] }, + func(s string) string { return s[:1] + "\uFEFF" + s[1:] }, + func(s string) string { return s[:1] + "\u2060" + s[1:] }, + func(s string) string { return s[:1] + "\u180E" + s[1:] }, + // NFKC compatibility decomposition must collapse fullwidth + // lookalikes into ASCII. + func(s string) string { + left, right, _ := strings.Cut(s, "@") + out := make([]rune, 0, len(left)) + for _, r := range left { + if r >= 'a' && r <= 'z' { + out = append(out, 0xFF41+(r-'a')) + continue + } + if r >= '0' && r <= '9' { + out = append(out, 0xFF10+(r-'0')) + continue + } + out = append(out, r) + } + return string(out) + "@" + right + }, + } { + ids := transform(email) + expected := passwordIdentity("", ids) + err := p.CreateIdentity(ctx, expected) + require.ErrorIs(t, err, sqlcon.ErrUniqueViolation(), "%+v", err) + + _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.Error(t, err) + + t.Run("succeeds on different network/id="+ids, func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + expected := passwordIdentity("", ids) + err := p.CreateIdentity(ctx, expected) + require.NoError(t, err) + + _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.NoError(t, err) + }) + } + }) + + t.Run("case=fail on duplicate credential identifiers if type is oidc", func(t *testing.T) { + oidcID := randx.MustString(16, randx.AlphaLowerNum) + initial := oidcIdentity("", oidcID) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + expected := oidcIdentity("", oidcID) + require.Error(t, p.CreateIdentity(ctx, expected)) + + _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.Error(t, err) + + second := oidcIdentity("", strings.ToUpper(oidcID)) + require.NoError(t, p.CreateIdentity(ctx, second), "should work because oidc is not case-sensitive") + createdIDs = append(createdIDs, second.ID) + + t.Run("succeeds on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + expected := oidcIdentity("", oidcID) + require.NoError(t, p.CreateIdentity(ctx, expected)) + + _, err = p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.NoError(t, err) + }) + }) + + t.Run("case=create with invalid traits data", func(t *testing.T) { + expected := oidcIdentity("", x.NewUUID().String()) + expected.Traits = identity.Traits(`{"bar":123}`) // bar should be a string + err := p.CreateIdentity(ctx, expected) + require.Error(t, err) + assert.Contains(t, fmt.Sprintf("%+v", err.Error()), "malformed") + }) + + t.Run("case=get classified credentials", func(t *testing.T) { + initial := oidcIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, Identifiers: []string{"aylmao-oidc"}, + Config: sqlxx.JSONRawMessage(`{"ay":"lmao"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + initial, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, initial.ID) + require.NotEmpty(t, initial.Credentials) + + t.Run("fails on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.GetIdentityConfidential(ctx, initial.ID) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=update an identity and set credentials", func(t *testing.T) { + initial := oidcIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + assert.Equal(t, config.DefaultIdentityTraitsSchemaID, initial.SchemaID) + assert.Equal(t, defaultSchema.SchemaURL(exampleServerURL).String(), initial.SchemaURL) + + expected := initial.CopyWithoutCredentials() + expected.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{"ignore-me"}, + Config: sqlxx.JSONRawMessage(`{"oh":"nono"}`), + }) + expected.Traits = identity.Traits(`{"update":"me"}`) + expected.SchemaID = altSchema.ID + require.NoError(t, p.UpdateIdentity(ctx, expected)) + + actual, err := p.GetIdentityConfidential(ctx, expected.ID) + require.NoError(t, err) + assert.Equal(t, altSchema.ID, actual.SchemaID) + assert.Equal(t, altSchema.SchemaURL(exampleServerURL).String(), actual.SchemaURL) + assert.NotEmpty(t, actual.Credentials[identity.CredentialsTypePassword]) + assert.Empty(t, actual.Credentials[identity.CredentialsTypeOIDC]) + + assert.Equal(t, expected.Credentials[identity.CredentialsTypeOIDC], actual.Credentials[identity.CredentialsTypeOIDC]) + + t.Run("fails on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + require.ErrorIs(t, p.UpdateIdentity(ctx, expected), sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=fail to update because validation fails", func(t *testing.T) { + initial := oidcIdentity("", x.NewUUID().String()) + + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + initial.Traits = identity.Traits(`{"bar":123}`) + err := p.UpdateIdentity(ctx, initial) + require.Error(t, err) + require.Contains(t, err.Error(), "malformed") + }) + + t.Run("case=update an identity column", func(t *testing.T) { + initial := oidcIdentity("", x.NewUUID().String()) + initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.NoAuthenticatorAssuranceLevel) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + initial.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.AuthenticatorAssuranceLevel1) + initial.State = identity.StateInactive + require.NoError(t, p.UpdateIdentityColumns(ctx, initial, "available_aal")) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + assert.Equal(t, string(identity.AuthenticatorAssuranceLevel1), actual.InternalAvailableAAL.String) + assert.Equal(t, identity.StateActive, actual.State, "the state remains unchanged") + }) + + t.Run("case=update external_id via UpdateIdentity", func(t *testing.T) { + externalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) + + initial := oidcIdentity("", x.NewUUID().String()) + initial.ExternalID = externalID + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + t.Run("case=updating other fields keeps external_id unchanged", func(t *testing.T) { + // Loaded twice so the from-database snapshot and the update + // target are independent objects, matching how the update is + // driven in production. + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + updated, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + + updated.State = identity.StateInactive + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + assert.Equal(t, identity.StateInactive, actual.State) + assert.Equal(t, externalID, actual.ExternalID, "external_id is preserved when it does not change") + }) + + t.Run("case=changing external_id persists the new value", func(t *testing.T) { + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + updated, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + + newExternalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) + updated.ExternalID = newExternalID + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + assert.Equal(t, newExternalID, actual.ExternalID) + }) + + t.Run("case=changing external_id to an existing one is rejected", func(t *testing.T) { + other := oidcIdentity("", x.NewUUID().String()) + otherExternalID := sqlxx.NullString("ext-update-" + randx.MustString(10, randx.AlphaNum)) + other.ExternalID = otherExternalID + require.NoError(t, p.CreateIdentity(ctx, other)) + createdIDs = append(createdIDs, other.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + updated, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + + updated.ExternalID = otherExternalID + require.Error(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB)), + "the external_id unique constraint is still enforced when the value changes") + }) + }) + + t.Run("case=should fail to insert identity because credentials from traits exist", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + first := passwordIdentity("", email) + first.Traits = identity.Traits(`{}`) + require.NoError(t, p.CreateIdentity(ctx, first)) + createdIDs = append(createdIDs, first.ID) + + second := passwordIdentity("", email) + require.Error(t, p.CreateIdentity(ctx, second)) + + t.Run("passes on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + require.NoError(t, p.CreateIdentity(ctx, second)) + }) + + t.Run("case=should fail to update identity because credentials exist", func(t *testing.T) { + first := passwordIdentity("", x.NewUUID().String()) + first.Traits = identity.Traits(`{}`) + require.NoError(t, p.CreateIdentity(ctx, first)) + createdIDs = append(createdIDs, first.ID) + + c := first.Credentials[identity.CredentialsTypePassword] + c.Identifiers = []string{email} + first.Credentials[identity.CredentialsTypePassword] = c + require.Error(t, p.UpdateIdentity(ctx, first)) + + t.Run("passes on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + first := passwordIdentity("", x.NewUUID().String()) + first.Traits = identity.Traits(`{}`) + require.NoError(t, p.CreateIdentity(ctx, first)) + + c := first.Credentials[identity.CredentialsTypePassword] + c.Identifiers = []string{"test-identity@ory.sh"} + first.Credentials[identity.CredentialsTypePassword] = c + require.NoError(t, p.UpdateIdentity(ctx, first)) + }) + }) + }) + + t.Run("case=should succeed to update credentials from traits", func(t *testing.T) { + expected := passwordIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + expected.Traits = identity.Traits(`{"email":"update-test-identity@ory.sh"}`) + require.NoError(t, p.UpdateIdentity(ctx, expected)) + + actual, err := p.GetIdentityConfidential(ctx, expected.ID) + require.NoError(t, err) + + assert.Equal(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, actual.Credentials[identity.CredentialsTypePassword].Identifiers) + }) + + t.Run("case=delete an identity", func(t *testing.T) { + expected := passwordIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, expected)) + + t.Run("fails on different network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + require.ErrorIs(t, p.DeleteIdentity(ctx, expected.ID), sqlcon.ErrNoRows()) + + p = testhelpers.ExistingNetwork(t, p, nid) + _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.NoError(t, err) + }) + + require.NoError(t, p.DeleteIdentity(ctx, expected.ID)) + + _, err := p.GetIdentity(ctx, expected.ID, identity.ExpandNothing) + require.Error(t, err) + }) + + t.Run("case=create with empty credentials config", func(t *testing.T) { + // This test covers a case where the config value of a credentials setting is empty. This causes + // issues with postgres' json field. + expected := passwordIdentity("", x.NewUUID().String()) + expected.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{"id-missing-creds-config"}, + Config: sqlxx.JSONRawMessage(``), + }) + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + }) + + t.Run("case=list", func(t *testing.T) { + is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault}) + require.NoError(t, err) + require.NotEmpty(t, is) + require.Len(t, is, len(createdIDs)) + for _, id := range createdIDs { + var found bool + for _, i := range is { + if i.ID == id { + expected, err := p.GetIdentity(ctx, id, identity.ExpandDefault) + require.NoError(t, err) + assertx.EqualAsJSON(t, expected, i) + found = true + } + } + require.True(t, found, id) + } + + t.Run("no results on other network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault}) + require.NoError(t, err) + assert.Len(t, is, 0) + }) + + t.Run("list some using ids filter", func(t *testing.T) { + filterIds := createdIDs[:2] + + is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{Expand: identity.ExpandDefault, IdsFilter: filterIds}) + require.NoError(t, err) + assert.Len(t, is, len(filterIds)) + }) + + t.Run("eventually consistent", func(t *testing.T) { + if dbname != "cockroach" { + t.Skipf("Test only works with cockroachdb") + return + } + + id := x.NewUUID().String() + another := oidcIdentity("", id) + require.NoError(t, p.CreateIdentity(ctx, another)) + createdIDs = append(createdIDs, another.ID) + + is, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + Expand: identity.ExpandDefault, + KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(25)}, + ConsistencyLevel: crdbx.ConsistencyLevelStrong, + }) + require.NoError(t, err) + require.Len(t, is, len(createdIDs)) + + // The data is replicated with eventual consistency (usually a few + // seconds). Poll until every identity is visible instead of sleeping + // a fixed 8 seconds, so the test returns as soon as replication + // completes. + listEventually := func() []identity.Identity { + // The error is intentionally ignored because the table / schema + // might not yet be replicated. This can surface as "cached plan + // must not change result type (SQLSTATE 0A000)" because the + // prepared query exists but the schema is not yet replicated. + is, _, _ := p.ListIdentities(ctx, identity.ListIdentityParameters{ + Expand: identity.ExpandEverything, + KeySetPagination: []keysetpagination.Option{keysetpagination.WithSize(25)}, + ConsistencyLevel: crdbx.ConsistencyLevelEventual, + }) + return is + } + // Assert the full count and the specific identity on the same + // snapshot inside the poll. A follower read can return a stale + // snapshot whose count matches len(createdIDs) but that does not + // yet contain the just-created identity, so checking the count and + // the identity against separate reads is racy. + require.EventuallyWithT(t, func(t *assert.CollectT) { + results := listEventually() + if !assert.Len(t, results, len(createdIDs), "Could not find all identities") { + return + } + var found bool + for _, i := range results { + if i.ID == another.ID { + found = true + } + } + assert.True(t, found, "Unable to find created identity %s in eventually consistent results.", another.ID) + }, 15*time.Second, 200*time.Millisecond) + }) + }) + + t.Run("case=find identity by its credentials identifier", func(t *testing.T) { + var expectedIdentifiers []string + var expectedIdentities []*identity.Identity + + for _, c := range []identity.CredentialsType{ + identity.CredentialsTypePassword, + identity.CredentialsTypeWebAuthn, + identity.CredentialsTypeOIDC, + } { + identityIdentifier := fmt.Sprintf("find-identity-by-identifier-%s@ory.sh", c) + expected := identity.NewIdentity("") + expected.SetCredentials(c, identity.Credentials{Type: c, Identifiers: []string{identityIdentifier}, Config: sqlxx.JSONRawMessage(`{}`)}) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + expectedIdentifiers = append(expectedIdentifiers, identityIdentifier) + expectedIdentities = append(expectedIdentities, expected) + } + + create := identity.NewIdentity("") + create.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{Type: identity.CredentialsTypePassword, Identifiers: []string{"find-identity-by-identifier-common@ory.sh"}, Config: sqlxx.JSONRawMessage(`{}`)}) + create.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{Type: identity.CredentialsTypeWebAuthn, Identifiers: []string{"find-identity-by-identifier-common@ory.sh"}, Config: sqlxx.JSONRawMessage(`{}`)}) + require.NoError(t, p.CreateIdentity(ctx, create)) + + actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + Expand: identity.ExpandEverything, + }) + require.NoError(t, err) + require.Greater(t, len(actual), 0) + + for c, ct := range []identity.CredentialsType{ + identity.CredentialsTypePassword, + identity.CredentialsTypeWebAuthn, + } { + t.Run(ct.String(), func(t *testing.T) { + actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + // Match is normalized + CredentialsIdentifier: expectedIdentifiers[c], + }) + require.NoError(t, err) + + expected := expectedIdentities[c] + require.Len(t, actual, 1) + assertx.EqualAsJSONExcept(t, expected, actual[0], []string{"credentials.config", "created_at", "updated_at", "state_changed_at", "region"}) + }) + } + + t.Run("similarity search", func(t *testing.T) { + actual, _, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + CredentialsIdentifierSimilar: "find-identity-by-identifier", + Expand: identity.ExpandCredentials, + }) + require.NoError(t, err) + assert.Len(t, actual, 4) // webauthn, common, password, oidc + + outer: + for _, e := range append(expectedIdentities[:2], create) { + for _, a := range actual { + if e.ID == a.ID { + assertx.EqualAsJSONExcept(t, e, a, []string{"credentials.config", "created_at", "updated_at", "state_changed_at", "region"}) + continue outer + } + } + actualCredentials := make([]map[identity.CredentialsType]identity.Credentials, len(actual)) + for k, a := range actual { + actualCredentials[k] = a.Credentials + } + t.Fatalf("expected identity %+v not found in actual result set %+v", e.Credentials, actualCredentials) + } + }) + + t.Run("find by OIDC identifier", func(t *testing.T) { + actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + CredentialsIdentifier: "find-identity-by-identifier-oidc@ory.sh", + Expand: identity.ExpandEverything, + }) + require.NoError(t, err) + assert.Len(t, actual, 1) + assert.True(t, next.IsLast()) + }) + + t.Run("one result set even if multiple matches", func(t *testing.T) { + actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + CredentialsIdentifier: "find-identity-by-identifier-common@ory.sh", + Expand: identity.ExpandEverything, + }) + require.NoError(t, err) + assert.Len(t, actual, 1) + assert.True(t, next.IsLast()) + }) + + t.Run("non existing identifier", func(t *testing.T) { + actual, next, err := p.ListIdentities(ctx, identity.ListIdentityParameters{ + CredentialsIdentifier: "find-identity-by-identifier-non-existing@ory.sh", + Expand: identity.ExpandEverything, + }) + require.NoError(t, err) + assert.Len(t, actual, 0) + assert.True(t, next.IsLast()) + }) + + t.Run("not if on another network", func(t *testing.T) { + _, on := testhelpers.NewNetwork(t, ctx, p) + actual, next, err := on.ListIdentities(ctx, identity.ListIdentityParameters{ + CredentialsIdentifier: expectedIdentifiers[0], + Expand: identity.ExpandEverything, + }) + require.NoError(t, err) + assert.Len(t, actual, 0) + assert.True(t, next.IsLast()) + }) + }) + + t.Run("case=find identity by its credentials type and identifier", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + expected := passwordIdentity("", email) + expected.Traits = identity.Traits(`{}`) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email) + require.NoError(t, err) + + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID) + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, creds.Identifiers) + assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(creds.Config)) + // assert.EqualValues(t, expected.Credentials[CredentialsTypePassword].CreatedAt.Unix(), creds.CreatedAt.Unix()) + // assert.EqualValues(t, expected.Credentials[CredentialsTypePassword].UpdatedAt.Unix(), creds.UpdatedAt.Unix()) + + require.Equal(t, expected.Traits, actual.Traits) + require.Equal(t, expected.ID, actual.ID) + require.NotNil(t, actual.Credentials[identity.CredentialsTypePassword]) + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, actual.Credentials[identity.CredentialsTypePassword].ID) + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].Identifiers, actual.Credentials[identity.CredentialsTypePassword].Identifiers) + assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(actual.Credentials[identity.CredentialsTypePassword].Config)) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find identity by its webauthn credential user handle", func(t *testing.T) { + expected := identity.NewIdentity("") + expected.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ + Type: identity.CredentialsTypeWebAuthn, + Identifiers: []string{"find-webauth-user-handle-identifier@ory.sh"}, + Config: sqlxx.JSONRawMessage(`{ + "credentials": [ + { + "added_at": "2024-02-13T10:36:16Z", + "attestation_type": "none", + "authenticator": { + "aaguid": "+/wwBxVOTsyMC24CBVfXvQ==", + "clone_warning": false, + "sign_count": 0 + }, + "display_name": "Yubikey", + "id": "f2uGd/Bg1rGcGXtYp4MT4WcN+eA=", + "is_passwordless": true, + "public_key": "pQECAyYgASFYIBkNvUxvjdhuA36FworTmS/rxZR1I+NyRWBpoTYY/R+CIlggw+gFFrFoEi+rS82zq7+tDHAukBUJcFpQ7Z3NLBZH5vk=" + } + ], + "user_handle": "51z80nYJTSGmr6UBe1VGLg==" +}`), + }) + expected.Traits = identity.Traits(`{}`) + userHandle := x.Must(base64.StdEncoding.DecodeString("51z80nYJTSGmr6UBe1VGLg==")) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.FindIdentityByWebauthnUserHandle(ctx, userHandle) + require.NoError(t, err) + + expected.Credentials = nil + assertEqual(t, expected, actual) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err = p.FindIdentityByWebauthnUserHandle(ctx, userHandle) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find identity only by credentials identifier", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + expected := passwordIdentity("", email) + expected.Traits = identity.Traits(`{}`) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), false, identity.ExpandDefault) + require.NoError(t, err) + + expected.Credentials = nil + assertEqual(t, expected, actual) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), false, identity.ExpandDefault) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find identity only by credentials identifier case sensitive", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + expected := passwordIdentity("", email) + expected.Traits = identity.Traits(`{}`) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + _, err := p.FindIdentityByCredentialIdentifier(ctx, strings.ToUpper(email), true, identity.ExpandDefault) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + actual, err := p.FindIdentityByCredentialIdentifier(ctx, email, true, identity.ExpandDefault) + require.NoError(t, err) + + expected.Credentials = nil + assertEqual(t, expected, actual) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindIdentityByCredentialIdentifier(ctx, email, true, identity.ExpandDefault) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find identity by phone number credential identifier with backward compatibility", func(t *testing.T) { + // Use unique phone numbers to avoid conflicts with other tests + nonNormalizedPhone := "+44 20 7946 0958" + normalizedPhone := "+442079460958" + + t.Run("upgraded identity with both identifiers via raw SQL", func(t *testing.T) { + // This test simulates a legacy identity that has been upgraded and persisted, + // so it has BOTH non-normalized and normalized identifiers in the database. + // This is the expected state after a user with legacy data logs in and updates settings. + + // Get credential type metadata + var credTypes []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&credTypes)) + var passwordCredType identity.CredentialsTypeTable + for _, ct := range credTypes { + if ct.Name == identity.CredentialsTypePassword { + passwordCredType = ct + break + } + } + + legacyID := x.NewUUID() + credID := x.NewUUID() + identifierID1 := x.NewUUID() + identifierID2 := x.NewUUID() + now := time.Now() + + // Cleanup after test + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() + }) + + // Insert identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + legacyID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert credential (version 1 after upgrade) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, + ).Exec()) + + // Insert BOTH identifiers (simulating post-upgrade state) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID1, legacyID, credID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID2, legacyID, credID, nid, normalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Test 1: Find with non-normalized phone + actualNonNorm, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find upgraded identity with non-normalized phone") + require.Equal(t, legacyID, actualNonNorm.ID) + + // Test 2: Find with normalized phone + actualNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find upgraded identity with normalized phone") + require.Equal(t, legacyID, actualNorm.ID) + + // Test 3: Case sensitive should only match exact + actualCaseSens, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, true, identity.ExpandDefault) + require.NoError(t, err) + require.Equal(t, legacyID, actualCaseSens.ID) + + // Test 4: Case sensitive with normalized should also match exact + actualCaseSensNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, true, identity.ExpandDefault) + require.NoError(t, err) + require.Equal(t, legacyID, actualCaseSensNorm.ID) + }) + + t.Run("new identity with normalized phone can be found with both formats", func(t *testing.T) { + // Create a new identity with normalized phone (simulating post-normalization data) + newIdentity := passwordIdentity("", normalizedPhone) + newIdentity.Traits = identity.Traits(`{}`) + newIdentity.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{normalizedPhone}, // Only normalized identifier + Config: newIdentity.Credentials[identity.CredentialsTypePassword].Config, + Version: 1, + } + + require.NoError(t, p.CreateIdentity(ctx, newIdentity)) + createdIDs = append(createdIDs, newIdentity.ID) + + // Test 1: Find with normalized phone (exact match in DB) + actualNorm, err := p.FindIdentityByCredentialIdentifier(ctx, normalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find new identity with normalized phone") + require.Equal(t, newIdentity.ID, actualNorm.ID) + + // Test 2: Find with non-normalized phone (gets normalized, then matches) + actualNonNorm, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find new identity with non-normalized phone (gets normalized)") + require.Equal(t, newIdentity.ID, actualNonNorm.ID) + }) + }) + + t.Run("case=find identity by credential identifier prefers exact match when multiple records exist", func(t *testing.T) { + // This test verifies that when multiple identities match (one with normalized, + // another with non-normalized), the function returns the one with exact match. + // Use unique phone numbers to avoid conflicts with other tests + nonNormalizedPhone := "+1 555-111-2222" + normalizedPhone := "+15551112222" + + // Get credential type metadata + var credTypes []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&credTypes)) + var passwordCredType identity.CredentialsTypeTable + for _, ct := range credTypes { + if ct.Name == identity.CredentialsTypePassword { + passwordCredType = ct + break + } + } + + // Create first identity with normalized phone number + normalizedID := x.NewUUID() + normalizedCredID := x.NewUUID() + normalizedIdentifierID := x.NewUUID() + now := time.Now() + + // Cleanup after test - will be executed in reverse order + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", normalizedCredID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", normalizedCredID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", normalizedID).Exec() + }) + + // Insert first identity with normalized phone + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + normalizedID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + normalizedCredID, normalizedID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, + ).Exec()) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + normalizedIdentifierID, normalizedID, normalizedCredID, nid, normalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Create second identity with non-normalized phone number + nonNormalizedID := x.NewUUID() + nonNormalizedCredID := x.NewUUID() + nonNormalizedIdentifierID := x.NewUUID() + + // Update cleanup to include second identity + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", nonNormalizedCredID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", nonNormalizedCredID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", nonNormalizedID).Exec() + }) + + // Insert second identity with non-normalized phone + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + nonNormalizedID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + nonNormalizedCredID, nonNormalizedID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, + ).Exec()) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + nonNormalizedIdentifierID, nonNormalizedID, nonNormalizedCredID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Test: Query with non-normalized phone should return the identity with exact match + // Even though both identities match (one has normalized, other has non-normalized), + // the function should prefer the exact match + actual, err := p.FindIdentityByCredentialIdentifier(ctx, nonNormalizedPhone, false, identity.ExpandDefault) + require.NoError(t, err, "should find identity with exact non-normalized phone match") + require.Equal(t, nonNormalizedID, actual.ID, "should return identity with exact match (non-normalized phone)") + require.NotEqual(t, normalizedID, actual.ID, "should not return identity with only normalized match") + }) + + t.Run("case=FindByCredentialsIdentifier with phone number backward compatibility", func(t *testing.T) { + t.Run("upgraded identity with both identifiers via raw SQL", func(t *testing.T) { + // Use unique phone numbers for this test + nonNormalizedPhone := "+1 555-987-6543" + normalizedPhone := "+15559876543" + // This test simulates a legacy identity that has been upgraded and persisted + // Get credential type metadata + var credTypes []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&credTypes)) + var passwordCredType identity.CredentialsTypeTable + for _, ct := range credTypes { + if ct.Name == identity.CredentialsTypePassword { + passwordCredType = ct + break + } + } + + legacyID := x.NewUUID() + credID := x.NewUUID() + identifierID1 := x.NewUUID() + identifierID2 := x.NewUUID() + now := time.Now() + + // Cleanup after test + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credential_identifiers WHERE identity_credential_id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_credentials WHERE id = ?", credID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", legacyID).Exec() + }) + + // Insert identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + legacyID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert credential (version 1 after upgrade) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + credID, legacyID, nid, passwordCredType.ID, now, now, sqlxx.JSONRawMessage(`{"foo":"bar"}`), 1, + ).Exec()) + + // Insert BOTH identifiers (simulating post-upgrade state) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID1, legacyID, credID, nid, nonNormalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + identifierID2, legacyID, credID, nid, normalizedPhone, now, now, passwordCredType.ID, + ).Exec()) + + // Test 1: Find with non-normalized phone + actualNonNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, nonNormalizedPhone) + require.NoError(t, err, "should find upgraded identity with non-normalized phone") + require.Equal(t, legacyID, actualNonNorm.ID) + require.NotNil(t, cred) + + // Test 2: Find with normalized phone + actualNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) + require.NoError(t, err, "should find upgraded identity with normalized phone") + require.Equal(t, legacyID, actualNorm.ID) + require.NotNil(t, cred) + }) + + t.Run("new identity with normalized phone", func(t *testing.T) { + // Use different phone numbers to avoid conflicts with previous test + // Using UK format which normalizes reliably + nonNormalizedPhone := "+44 161 496 0123" + normalizedPhone := "+441614960123" + + // Create new identity with normalized phone + newIdentity := passwordIdentity("", normalizedPhone) + newIdentity.Traits = identity.Traits(`{}`) + newIdentity.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{normalizedPhone}, + Config: newIdentity.Credentials[identity.CredentialsTypePassword].Config, + Version: 1, + } + + require.NoError(t, p.CreateIdentity(ctx, newIdentity)) + createdIDs = append(createdIDs, newIdentity.ID) + + // Test 1: Find with normalized phone (exact match) + actualNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, normalizedPhone) + require.NoError(t, err) + require.Equal(t, newIdentity.ID, actualNorm.ID) + require.NotNil(t, cred) + + // Test 2: Find with non-normalized phone (gets normalized) + actualNonNorm, cred, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, nonNormalizedPhone) + require.NoError(t, err) + require.Equal(t, newIdentity.ID, actualNonNorm.ID) + require.NotNil(t, cred) + }) + }) + + t.Run("case=find identity by its credentials respects cases", func(t *testing.T) { + baseEmail := randx.MustString(16, randx.AlphaLowerNum) + caseSensitive := baseEmail + "@ory.sh" + caseInsensitiveWithSpaces := " " + strings.ToUpper(baseEmail) + "@ORY.sh " + + expected := identity.NewIdentity("") + for _, c := range []identity.CredentialsType{ + identity.CredentialsTypePassword, + identity.CredentialsTypeOIDC, + identity.CredentialsTypeTOTP, + identity.CredentialsTypeLookup, + identity.CredentialsTypeWebAuthn, + } { + expected.SetCredentials(c, identity.Credentials{Type: c, Identifiers: []string{caseSensitive}, Config: sqlxx.JSONRawMessage(`{}`)}) + } + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + t.Run("case sensitive", func(t *testing.T) { + for _, ct := range []identity.CredentialsType{ + identity.CredentialsTypeOIDC, + identity.CredentialsTypeTOTP, + identity.CredentialsTypeLookup, + } { + t.Run(ct.String(), func(t *testing.T) { + _, _, err := p.FindByCredentialsIdentifier(ctx, ct, caseInsensitiveWithSpaces) + require.Error(t, err) + + actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, caseSensitive) + require.NoError(t, err) + assertx.EqualAsJSONExcept(t, expected.Credentials[ct], creds, []string{"created_at", "updated_at", "id"}) + assertx.EqualAsJSONExcept(t, expected, actual, []string{"created_at", "state_changed_at", "updated_at", "id", "region"}) + }) + } + }) + + t.Run("case insensitive", func(t *testing.T) { + for _, ct := range []identity.CredentialsType{ + identity.CredentialsTypePassword, + identity.CredentialsTypeWebAuthn, + } { + t.Run(ct.String(), func(t *testing.T) { + for _, cs := range []string{caseSensitive, caseInsensitiveWithSpaces} { + actual, creds, err := p.FindByCredentialsIdentifier(ctx, ct, cs) + require.NoError(t, err) + ec := expected.Credentials[ct] + ec.Identifiers = []string{strings.ToLower(caseSensitive)} + assertx.EqualAsJSONExcept(t, ec, creds, []string{"created_at", "updated_at", "id", "config.user_handle", "config.credentials", "version"}) + assertx.EqualAsJSONExcept(t, expected, actual, []string{"created_at", "state_changed_at", "updated_at", "id", "region"}) + } + }) + } + }) + }) + + t.Run("case=find identity by its credentials case insensitive", func(t *testing.T) { + identifier := strings.ToUpper(x.NewUUID().String()) + expected := passwordIdentity("", identifier) + expected.Traits = identity.Traits(`{}`) + + require.NoError(t, p.CreateIdentity(ctx, expected)) + createdIDs = append(createdIDs, expected.ID) + + actual, creds, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier) + require.NoError(t, err) + + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, creds.ID) + assert.EqualValues(t, []string{strings.ToLower(identifier)}, creds.Identifiers) + assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(creds.Config)) + + require.Equal(t, expected.Traits, actual.Traits) + require.Equal(t, expected.ID, actual.ID) + require.NotNil(t, actual.Credentials[identity.CredentialsTypePassword]) + assert.EqualValues(t, expected.Credentials[identity.CredentialsTypePassword].ID, actual.Credentials[identity.CredentialsTypePassword].ID) + assert.EqualValues(t, []string{strings.ToLower(identifier)}, actual.Credentials[identity.CredentialsTypePassword].Identifiers) + assert.JSONEq(t, string(expected.Credentials[identity.CredentialsTypePassword].Config), string(actual.Credentials[identity.CredentialsTypePassword].Config)) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, _, err := p.FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("suite=verifiable-address", func(t *testing.T) { + createIdentityWithAddresses := func(t *testing.T, email string) identity.VerifiableAddress { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + + address := identity.NewVerifiableEmailAddress(email, i.ID) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + return i.VerifiableAddresses[0] + } + + t.Run("case=not found", func(t *testing.T) { + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "does-not-exist") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + transform := func(k int, value string) string { + switch k % 5 { + case 0: + value = strings.ToLower(value) + case 1: + value = strings.ToUpper(value) + case 2: + value = " " + value + case 3: + value = value + " " + } + return value + } + + t.Run("case=create and find", func(t *testing.T) { + addresses := make([]identity.VerifiableAddress, 15) + for k := range addresses { + value := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + addresses[k] = createIdentityWithAddresses(t, transform(k, value)) + require.NotEmpty(t, addresses[k].ID) + } + + compare := func(t *testing.T, expected, actual identity.VerifiableAddress) { + actual.CreatedAt = actual.CreatedAt.UTC().Truncate(time.Hour * 24) + actual.UpdatedAt = actual.UpdatedAt.UTC().Truncate(time.Hour * 24) + expected.CreatedAt = expected.CreatedAt.UTC().Truncate(time.Hour * 24) + expected.UpdatedAt = expected.UpdatedAt.UTC().Truncate(time.Hour * 24) + expected.Value = strings.TrimSpace(strings.ToLower(expected.Value)) + assert.EqualValues(t, expected, actual) + } + + for k, expected := range addresses { + t.Run("method=FindVerifiableAddressByValue", func(t *testing.T) { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + actual, err := p.FindVerifiableAddressByValue(ctx, expected.Via, transform(k+1, expected.Value)) + require.NoError(t, err) + compare(t, expected, *actual) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, expected.Via, transform(k+1, expected.Value)) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + }) + } + }) + + t.Run("case=update", func(t *testing.T) { + address := createIdentityWithAddresses(t, "verification.TestPersister.Update@ory.sh ") + + address.Value = "new-codE " + require.NoError(t, p.UpdateVerifiableAddress(ctx, &address)) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + require.ErrorIs(t, p.UpdateVerifiableAddress(ctx, &address), sqlcon.ErrNoRows()) + }) + + actual, err := p.FindVerifiableAddressByValue(ctx, address.Via, address.Value) + require.NoError(t, err) + assert.Equal(t, "new-code", actual.Value) + }) + + t.Run("case=create and update and find", func(t *testing.T) { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + + address := identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity@ory.sh", i.ID) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) + require.NoError(t, p.CreateIdentity(ctx, &i)) + + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") + require.NoError(t, err) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + address = identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-next@ory.sh", i.ID) + i.VerifiableAddresses = []identity.VerifiableAddress{*address} + require.NoError(t, p.UpdateIdentity(ctx, &i)) + + _, err = p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") + require.EqualError(t, err, sqlcon.ErrNoRows().Error()) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity@ory.sh") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-next@ory.sh") + require.NoError(t, err) + assert.Equal(t, identity.AddressTypeEmail, actual.Via) + assert.Equal(t, "verification.testpersister.update-identity-next@ory.sh", actual.Value) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-next@ory.sh") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=create and update and find case insensitive", func(t *testing.T) { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + + address := identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-case-insensitive@ory.sh", i.ID) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) + require.NoError(t, p.CreateIdentity(ctx, &i)) + + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) + require.NoError(t, err) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + address = identity.NewVerifiableEmailAddress("verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh", i.ID) + i.VerifiableAddresses = []identity.VerifiableAddress{*address} + require.NoError(t, p.UpdateIdentity(ctx, &i)) + + _, err = p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) + require.EqualError(t, err, sqlcon.ErrNoRows().Error()) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive@ory.sh")) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh")) + require.NoError(t, err) + assert.Equal(t, identity.AddressTypeEmail, actual.Via) + assert.Equal(t, "verification.testpersister.update-identity-case-insensitive-next@ory.sh", actual.Value) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "verification.TestPersister.Update-Identity-case-insensitive-next@ory.sh") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find verifiable address by phone number with normalization", func(t *testing.T) { + // Use unique phone numbers to avoid conflicts with other tests + nonNormalizedPhone := "+1 650-253-0000" + normalizedPhone := "+16502530000" + + t.Run("create with normalized phone and find with both formats", func(t *testing.T) { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + + // Create identity with normalized phone + address := identity.NewVerifiableAddress(normalizedPhone, i.ID, identity.AddressTypeSMS) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find with normalized phone (exact match in DB) + actualNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) + require.NoError(t, err, "should find verifiable address with normalized phone") + assert.Equal(t, normalizedPhone, actualNorm.Value) + assert.Equal(t, identity.AddressTypeSMS, actualNorm.Via) + + // Test 2: Find with non-normalized phone (gets normalized, then matches) + actualNonNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, nonNormalizedPhone) + require.NoError(t, err, "should find verifiable address with non-normalized phone (gets normalized)") + assert.Equal(t, normalizedPhone, actualNonNorm.Value) + assert.Equal(t, actualNorm.ID, actualNonNorm.ID, "should return same address") + }) + + t.Run("create with non-normalized phone and find with both formats", func(t *testing.T) { + // Use different phone number for this test + nonNormPhone2 := "+44 20 7946 0958" + normPhone2 := "+442079460958" + + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + + // Create identity with non-normalized phone + // Note: CreateIdentity will normalize the phone during creation + address := identity.NewVerifiableAddress(nonNormPhone2, i.ID, identity.AddressTypeSMS) + i.VerifiableAddresses = append(i.VerifiableAddresses, *address) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find with non-normalized phone (gets normalized for search) + actualNonNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, nonNormPhone2) + require.NoError(t, err, "should find verifiable address with non-normalized phone") + assert.Equal(t, normPhone2, actualNonNorm.Value, "stored value should be normalized") + assert.Equal(t, identity.AddressTypeSMS, actualNonNorm.Via) + + // Test 2: Find with normalized phone (direct match) + actualNorm, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normPhone2) + require.NoError(t, err, "should find verifiable address with normalized phone") + assert.Equal(t, normPhone2, actualNorm.Value) + assert.Equal(t, actualNonNorm.ID, actualNorm.ID, "should return same address") + }) + + t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { + // This test simulates legacy data that was stored before phone normalization was implemented + // The database contains a non-normalized phone number + legacyPhone := "+1 650-253-0500" + + identityID := x.NewUUID() + addressID := x.NewUUID() + now := time.Now() + + // Cleanup after test + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_verifiable_addresses WHERE id = ?", addressID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() + }) + + // Insert identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert verifiable address with NON-NORMALIZED phone (legacy data) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_verifiable_addresses (id, identity_id, nid, via, value, verified, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, false, identity.VerifiableAddressStatusPending, now, now, + ).Exec()) + + // Test: Find with exact non-normalized phone (what user would enter) + // Should find the legacy non-normalized phone data + actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) + require.NoError(t, err, "should find legacy non-normalized phone data with non-normalized search") + assert.Equal(t, legacyPhone, actual.Value) + assert.Equal(t, identity.AddressTypeSMS, actual.Via) + assert.Equal(t, addressID, actual.ID) + }) + + t.Run("post-migration: legacy verifiable address normalized in DB", func(t *testing.T) { + legacyPhone := "+1 650-253-0600" + normalizedPhone := "+16502530600" + + identityID := x.NewUUID() + addressID := x.NewUUID() + now := time.Now() + + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_verifiable_addresses WHERE id = ?", addressID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() + }) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert with non-normalized phone, then simulate migration + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_verifiable_addresses (id, identity_id, nid, via, value, verified, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, false, identity.VerifiableAddressStatusPending, now, now, + ).Exec()) + + // Simulate migration: normalize the value in DB + require.NoError(t, p.GetConnection(ctx).RawQuery( + "UPDATE identity_verifiable_addresses SET value = ? WHERE id = ?", + normalizedPhone, addressID, + ).Exec()) + + // Find by E.164 + actual, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) + require.NoError(t, err, "should find migrated verifiable address by E.164") + assert.Equal(t, normalizedPhone, actual.Value) + + // Find by original non-normalized format (IN query normalizes input) + actual2, err := p.FindVerifiableAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) + require.NoError(t, err, "should find migrated verifiable address by non-normalized phone") + assert.Equal(t, normalizedPhone, actual2.Value) + assert.Equal(t, actual.ID, actual2.ID) + }) + }) + }) + + t.Run("suite=credential-types", func(t *testing.T) { + for _, ct := range identity.AllCredentialTypes { + t.Run("type="+ct.String(), func(t *testing.T) { + id, err := idpersistence.FindIdentityCredentialsTypeByName(p.GetConnection(ctx), ct) + require.NoError(t, err) + + require.NotEqual(t, uuid.Nil, id) + name, err := idpersistence.FindIdentityCredentialsTypeByID(p.GetConnection(ctx), id) + require.NoError(t, err) + + assert.Equal(t, ct, name) + }) + } + + _, err := idpersistence.FindIdentityCredentialsTypeByName(p.GetConnection(ctx), "unknown") + require.Error(t, err) + + _, err = idpersistence.FindIdentityCredentialsTypeByID(p.GetConnection(ctx), x.NewUUID()) + require.Error(t, err) + }) + + t.Run("suite=recovery-address", func(t *testing.T) { + createIdentityWithAddresses := func(t *testing.T, email string) *identity.Identity { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + i.Traits = []byte(`{"email":"` + email + `"}`) + address := identity.NewRecoveryEmailAddress(email, i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *address) + + addressOther := identity.NewRecoveryEmailAddress(email+"_other", i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *addressOther) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + return &i + } + + t.Run("case=not found", func(t *testing.T) { + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, "does-not-exist") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, "does-not-exist") + require.NoError(t, err) + require.Len(t, allAddresses, 0) + }) + + t.Run("case=create and find", func(t *testing.T) { + addresses := make([]identity.RecoveryAddress, 15) + for k := range addresses { + addresses[k] = createIdentityWithAddresses(t, randx.MustString(16, randx.AlphaLowerNum)+"@ory.sh").RecoveryAddresses[0] + require.NotEmpty(t, addresses[k].ID) + } + + compare := func(t *testing.T, expected, actual identity.RecoveryAddress) { + actual.CreatedAt = actual.CreatedAt.UTC().Truncate(time.Hour * 24) + actual.UpdatedAt = actual.UpdatedAt.UTC().Truncate(time.Hour * 24) + expected.CreatedAt = expected.CreatedAt.UTC().Truncate(time.Hour * 24) + expected.UpdatedAt = expected.UpdatedAt.UTC().Truncate(time.Hour * 24) + assert.EqualValues(t, expected, actual) + } + + for k, expected := range addresses { + t.Run("method=FindRecoveryAddressByValue", func(t *testing.T) { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + actual, err := p.FindRecoveryAddressByValue(ctx, expected.Via, expected.Value) + require.NoError(t, err) + compare(t, expected, *actual) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, expected.Via, expected.Value) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + }) + + t.Run("method=FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue", func(t *testing.T) { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, expected.Value) + require.NoError(t, err) + require.Len(t, allAddresses, 2) + require.Equal(t, expected.Value, allAddresses[0]) + require.Equal(t, expected.Value+"_other", allAddresses[1]) + }) + + t.Run("not if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, expected.Value) + require.NoError(t, err) + require.Len(t, allAddresses, 0) + }) + }) + } + }) + + t.Run("case=create and update and find", func(t *testing.T) { + email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + emailLower := strings.ToLower(email) + id := createIdentityWithAddresses(t, email) + + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) + require.NoError(t, err) + + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) + require.NoError(t, err) + require.Len(t, allAddresses, 2) + require.Equal(t, allAddresses[0], emailLower) + require.Equal(t, allAddresses[1], emailLower+"_other") + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) + require.NoError(t, err) + require.Len(t, allAddresses, 0) + }) + + emailNext := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + id.RecoveryAddresses = []identity.RecoveryAddress{{Via: identity.AddressTypeEmail, Value: emailNext}, {Via: identity.AddressTypeEmail, Value: emailNext + "_other"}} + require.NoError(t, p.UpdateIdentity(ctx, id)) + + _, err = p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) + require.EqualError(t, err, sqlcon.ErrNoRows().Error()) + + allAddresses, err = p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) + require.NoError(t, err) + require.Len(t, allAddresses, 0) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, email) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailLower) + require.NoError(t, err) + require.Len(t, allAddresses, 0) + }) + + emailNextLower := strings.ToLower(emailNext) + actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, emailNext) + require.NoError(t, err) + assert.Equal(t, identity.AddressTypeEmail, actual.Via) + assert.Equal(t, emailNextLower, actual.Value) + + allAddresses, err = p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailNextLower) + require.NoError(t, err) + require.Len(t, allAddresses, 2) + assert.Equal(t, emailNextLower, allAddresses[0]) + assert.Equal(t, emailNextLower+"_other", allAddresses[1]) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, emailNext) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, emailNextLower) + require.NoError(t, err) + require.Len(t, allAddresses, 0) + }) + }) + + t.Run("case=create and update and find case insensitive", func(t *testing.T) { + id := createIdentityWithAddresses(t, "recovery.TestPersister.Update-case-insensitive@ory.sh") + + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) + require.NoError(t, err) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("Recovery.TestPersister.Update-case-insensitive@ory.sh")) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + id.RecoveryAddresses = []identity.RecoveryAddress{{Via: identity.AddressTypeEmail, Value: "recovery.TestPersister.Update-case-insensitive-next@ory.sh"}} + require.NoError(t, p.UpdateIdentity(ctx, id)) + + _, err = p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) + require.EqualError(t, err, sqlcon.ErrNoRows().Error()) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive@ory.sh")) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + + actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive-next@ory.sh")) + require.NoError(t, err) + assert.Equal(t, identity.AddressTypeEmail, actual.Via) + assert.Equal(t, "recovery.testpersister.update-case-insensitive-next@ory.sh", actual.Value) + + t.Run("can not find if on another network", func(t *testing.T) { + _, p := testhelpers.NewNetwork(t, ctx, p) + _, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeEmail, strings.ToUpper("recovery.TestPersister.Update-case-insensitive-next@ory.sh")) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + }) + }) + + t.Run("case=find recovery address by phone number with normalization", func(t *testing.T) { + // Use unique phone numbers to avoid conflicts with other tests + nonNormalizedPhone := "+1 650-253-0001" + normalizedPhone := "+16502530001" + + t.Run("create with normalized phone and find with both formats", func(t *testing.T) { + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + i.Traits = []byte(`{"phone":"` + normalizedPhone + `"}`) + + // Create identity with normalized phone + address := identity.NewRecoverySMSAddress(normalizedPhone, i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *address) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find with normalized phone (exact match in DB) + actualNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) + require.NoError(t, err, "should find recovery address with normalized phone") + assert.Equal(t, normalizedPhone, actualNorm.Value) + assert.Equal(t, identity.AddressTypeSMS, actualNorm.Via) + + // Test 2: Find with non-normalized phone (gets normalized, then matches) + actualNonNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, nonNormalizedPhone) + require.NoError(t, err, "should find recovery address with non-normalized phone (gets normalized)") + assert.Equal(t, normalizedPhone, actualNonNorm.Value) + assert.Equal(t, actualNorm.ID, actualNonNorm.ID, "should return same address") + }) + + t.Run("create with non-normalized phone and find with both formats", func(t *testing.T) { + // Use different phone number for this test + nonNormPhone2 := "+44 20 7946 0123" + normPhone2 := "+442079460123" + + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + i.Traits = []byte(`{"phone":"` + nonNormPhone2 + `"}`) + + // Create identity with non-normalized phone + // Note: CreateIdentity will normalize the phone during creation + address := identity.NewRecoverySMSAddress(nonNormPhone2, i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *address) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find with non-normalized phone (gets normalized for search) + actualNonNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, nonNormPhone2) + require.NoError(t, err, "should find recovery address with non-normalized phone") + assert.Equal(t, normPhone2, actualNonNorm.Value, "stored value should be normalized") + assert.Equal(t, identity.AddressTypeSMS, actualNonNorm.Via) + + // Test 2: Find with normalized phone (direct match) + actualNorm, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normPhone2) + require.NoError(t, err, "should find recovery address with normalized phone") + assert.Equal(t, normPhone2, actualNorm.Value) + assert.Equal(t, actualNonNorm.ID, actualNorm.ID, "should return same address") + }) + + t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { + // This test simulates legacy data that was stored before phone normalization was implemented + // The database contains a non-normalized phone number + legacyPhone := "+1 650-253-0600" + + identityID := x.NewUUID() + addressID := x.NewUUID() + now := time.Now() + + // Cleanup after test + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id = ?", addressID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() + }) + + // Insert identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert recovery address with NON-NORMALIZED phone (legacy data) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, now, now, + ).Exec()) + + // Test: Find with exact non-normalized phone (what user would enter) + // Should find the legacy non-normalized phone data + actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) + require.NoError(t, err, "should find legacy non-normalized phone data with non-normalized search") + assert.Equal(t, legacyPhone, actual.Value) + assert.Equal(t, identity.AddressTypeSMS, actual.Via) + assert.Equal(t, addressID, actual.ID) + }) + + t.Run("post-migration: legacy recovery address normalized in DB", func(t *testing.T) { + legacyPhone := "+1 650-253-0700" + normalizedPhone := "+16502530700" + + identityID := x.NewUUID() + addressID := x.NewUUID() + now := time.Now() + + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id = ?", addressID).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() + }) + + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert with non-normalized phone, then simulate migration + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + addressID, identityID, nid, identity.AddressTypeSMS, legacyPhone, now, now, + ).Exec()) + + // Simulate migration: normalize the value in DB + require.NoError(t, p.GetConnection(ctx).RawQuery( + "UPDATE identity_recovery_addresses SET value = ? WHERE id = ?", + normalizedPhone, addressID, + ).Exec()) + + // Find by E.164 + actual, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, normalizedPhone) + require.NoError(t, err, "should find migrated recovery address by E.164") + assert.Equal(t, normalizedPhone, actual.Value) + + // Find by original non-normalized format + actual2, err := p.FindRecoveryAddressByValue(ctx, identity.AddressTypeSMS, legacyPhone) + require.NoError(t, err, "should find migrated recovery address by non-normalized phone") + assert.Equal(t, normalizedPhone, actual2.Value) + assert.Equal(t, actual.ID, actual2.ID) + }) + }) + + t.Run("case=find all recovery addresses by phone number with normalization", func(t *testing.T) { + t.Run("create with multiple SMS addresses and find all with normalized phone", func(t *testing.T) { + // Use unique phone numbers to avoid conflicts with other tests + nonNormalizedPhone1 := "+1 650-253-0100" + normalizedPhone1 := "+16502530100" + normalizedPhone2 := "+16502530200" + + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + i.Traits = []byte(`{"phone":"` + normalizedPhone1 + `"}`) + + // Create identity with two SMS recovery addresses + address1 := identity.NewRecoverySMSAddress(normalizedPhone1, i.ID) + address2 := identity.NewRecoverySMSAddress(normalizedPhone2, i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *address1, *address2) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find all addresses using normalized phone1 + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normalizedPhone1) + require.NoError(t, err, "should find all recovery addresses with normalized phone") + require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") + assert.Equal(t, normalizedPhone1, allAddresses[0]) + assert.Equal(t, normalizedPhone2, allAddresses[1]) + + // Test 2: Find all addresses using non-normalized phone1 (gets normalized, then matches) + allAddresses2, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, nonNormalizedPhone1) + require.NoError(t, err, "should find all recovery addresses with non-normalized phone") + require.Len(t, allAddresses2, 2, "should return all recovery addresses when querying with non-normalized phone") + assert.Equal(t, normalizedPhone1, allAddresses2[0]) + assert.Equal(t, normalizedPhone2, allAddresses2[1]) + + // Test 3: Find all addresses using normalized phone2 + allAddresses3, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normalizedPhone2) + require.NoError(t, err) + require.Len(t, allAddresses3, 2, "should return all recovery addresses when querying with second phone") + assert.Equal(t, normalizedPhone1, allAddresses3[0]) + assert.Equal(t, normalizedPhone2, allAddresses3[1]) + }) + + t.Run("create with multiple SMS addresses and find all with non-normalized phone", func(t *testing.T) { + // Use different phone numbers for this test + nonNormPhone1 := "+1 650-253-0300" + normPhone1 := "+16502530300" + normPhone2 := "+16502530400" + + var i identity.Identity + require.NoError(t, faker.FakeData(&i)) + i.Traits = []byte(`{"phone":"` + nonNormPhone1 + `"}`) + + // Create identity with two SMS recovery addresses + // Note: CreateIdentity will normalize both phones during creation + address1 := identity.NewRecoverySMSAddress(nonNormPhone1, i.ID) + address2 := identity.NewRecoverySMSAddress(normPhone2, i.ID) + i.RecoveryAddresses = append(i.RecoveryAddresses, *address1, *address2) + + require.NoError(t, p.CreateIdentity(ctx, &i)) + createdIDs = append(createdIDs, i.ID) + + // Test 1: Find all addresses using non-normalized phone1 (gets normalized for search) + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, nonNormPhone1) + require.NoError(t, err, "should find all recovery addresses with non-normalized phone") + require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") + assert.Equal(t, normPhone1, allAddresses[0], "stored values should be normalized") + assert.Equal(t, normPhone2, allAddresses[1]) + + // Test 2: Find all addresses using normalized phone1 (direct match) + allAddresses2, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, normPhone1) + require.NoError(t, err) + require.Len(t, allAddresses2, 2, "should return all recovery addresses when querying with normalized phone") + assert.Equal(t, normPhone1, allAddresses2[0]) + assert.Equal(t, normPhone2, allAddresses2[1]) + }) + + t.Run("backward compatibility with legacy non-normalized phone data", func(t *testing.T) { + // This test simulates legacy data that was stored before phone normalization was implemented + // The database contains non-normalized phone numbers + // + // SECURITY NOTE: This function returns ALL recovery addresses for an identity when ANY ONE matches. + // The implementation must ensure it only matches addresses for the SAME identity and doesn't + // accidentally return addresses from different identities that might have similar phone numbers. + legacyPhone1 := "+1 650-253-0700" + legacyPhone2 := "+1 650-253-0800" + + identityID := x.NewUUID() + addressID1 := x.NewUUID() + addressID2 := x.NewUUID() + now := time.Now() + + // Cleanup after test + t.Cleanup(func() { + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identity_recovery_addresses WHERE id IN (?, ?)", addressID1, addressID2).Exec() + _ = p.GetConnection(ctx).RawQuery("DELETE FROM identities WHERE id = ?", identityID).Exec() + }) + + // Insert identity with raw SQL + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at, state) VALUES (?, ?, ?, ?, ?, ?, ?)", + identityID, nid, config.DefaultIdentityTraitsSchemaID, sqlxx.JSONRawMessage(`{}`), now, now, identity.StateActive, + ).Exec()) + + // Insert TWO recovery addresses with NON-NORMALIZED phones (legacy data) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + addressID1, identityID, nid, identity.AddressTypeSMS, legacyPhone1, now, now, + ).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery( + "INSERT INTO identity_recovery_addresses (id, identity_id, nid, via, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + addressID2, identityID, nid, identity.AddressTypeSMS, legacyPhone2, now, now, + ).Exec()) + + // Test: Find all addresses with exact non-normalized phone (what user would enter) + // Should find all recovery addresses for the identity + allAddresses, err := p.FindAllRecoveryAddressValuesForIdentityByRecoveryAddressValue(ctx, legacyPhone1) + require.NoError(t, err, "should find all recovery addresses with non-normalized search") + require.Len(t, allAddresses, 2, "should return all recovery addresses for the identity") + assert.Equal(t, legacyPhone1, allAddresses[0]) + assert.Equal(t, legacyPhone2, allAddresses[1]) + }) + }) + }) + + t.Run("network reference isolation", func(t *testing.T) { + nid1, p := testhelpers.NewNetwork(t, ctx, p) + nid2, _ := testhelpers.NewNetwork(t, ctx, p) + + var m []identity.CredentialsTypeTable + require.NoError(t, p.GetConnection(ctx).All(&m)) + + iid := x.NewUUID() + require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid, nid1, time.Now(), time.Now()).Exec()) + + cid1, cid2 := x.NewUUID(), x.NewUUID() + require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config) VALUES (?, ?, ?, ?, ?, ?, '{}')", cid1, iid, nid1, m[0].ID, time.Now(), time.Now()).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credentials (id, identity_id, nid, identity_credential_type_id, created_at, updated_at, config) VALUES (?, ?, ?, ?, ?, ?, '{}')", cid2, iid, nid2, m[0].ID, time.Now(), time.Now()).Exec()) + + ici1, ici2 := x.NewUUID(), x.NewUUID() + require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ici1, iid, cid1, nid1, "nid1", time.Now(), time.Now(), m[0].ID).Exec()) + require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identity_credential_identifiers (id, identity_id, identity_credential_id, nid, identifier, created_at, updated_at, identity_credential_type_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ici2, iid, cid2, nid2, "nid2", time.Now(), time.Now(), m[0].ID).Exec()) + + _, err := p.GetIdentity(ctx, nid1, identity.ExpandNothing) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + _, err = p.GetIdentityConfidential(ctx, nid1) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + i, c, err := p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid1") + require.NoError(t, err) + assert.Equal(t, "nid1", c.Identifiers[0]) + require.Len(t, i.Credentials, 1) + + _, _, err = p.FindByCredentialsIdentifier(ctx, m[0].Name, "nid2") + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + + i, err = p.GetIdentityConfidential(ctx, iid) + require.NoError(t, err) + require.Len(t, i.Credentials, 1) + assert.Equal(t, "nid1", i.Credentials[m[0].Name].Identifiers[0]) + }) + + t.Run("suite=update-verifiable-addresses-edge-cases", func(t *testing.T) { + t.Run("case=add new verifiable addresses", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + originalEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + new1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + new2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: originalEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.VerifiableAddresses, 1) + + // Add two new addresses + updated := fromDB.CopyWithoutCredentials() + updated.VerifiableAddresses = append(updated.VerifiableAddresses, + identity.VerifiableAddress{Value: new1Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + identity.VerifiableAddress{Value: new2Email, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + ) + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.VerifiableAddresses, 3) + + values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value, actual.VerifiableAddresses[2].Value} + assertContainsValues(t, values, []string{originalEmail, new1Email, new2Email}, nil) + + // Verify the new verified address has verified_at set + for _, addr := range actual.VerifiableAddresses { + if addr.Value == new2Email { + assert.True(t, addr.Verified) + assert.NotNil(t, addr.VerifiedAt) + } + } + }) + + t.Run("case=remove all verifiable addresses", func(t *testing.T) { + email1 := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + email2 := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + initial := passwordIdentity("", x.NewUUID().String()) + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: email1, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + {Value: email2, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.VerifiableAddresses, 2) + + // Remove all addresses + updated := fromDB.CopyWithoutCredentials() + updated.VerifiableAddresses = []identity.VerifiableAddress{} + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + assert.Len(t, actual.VerifiableAddresses, 0) + }) + + t.Run("case=remove some and add some verifiable addresses", func(t *testing.T) { + keepEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + removeEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + addEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + initial := passwordIdentity("", x.NewUUID().String()) + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: keepEmail, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + {Value: removeEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.VerifiableAddresses, 2) + + // Keep one, remove one, add one + updated := fromDB.CopyWithoutCredentials() + var keptAddress identity.VerifiableAddress + for _, addr := range updated.VerifiableAddresses { + if addr.Value == keepEmail { + keptAddress = addr + break + } + } + updated.VerifiableAddresses = []identity.VerifiableAddress{ + keptAddress, + {Value: addEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusSent}, + } + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.VerifiableAddresses, 2) + + values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value} + assertContainsValues(t, values, []string{keepEmail, addEmail}, []string{removeEmail}) + }) + + t.Run("case=update existing verifiable address properties", func(t *testing.T) { + changeEmail := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + initial := passwordIdentity("", x.NewUUID().String()) + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: changeEmail, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + oldAddr := fromDB.VerifiableAddresses[0] + assert.False(t, oldAddr.Verified) + assert.Nil(t, oldAddr.VerifiedAt) + + // Change the address value - this should be treated as removal + addition + updated := fromDB.CopyWithoutCredentials() + updated.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: changeEmail, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + } + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.VerifiableAddresses, 1) + assert.Equal(t, changeEmail, actual.VerifiableAddresses[0].Value) + assert.True(t, actual.VerifiableAddresses[0].Verified) + assert.NotNil(t, actual.VerifiableAddresses[0].VerifiedAt) + }) + + t.Run("case=replace all verifiable addresses at once", func(t *testing.T) { + old1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + old2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + old3Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + initial := passwordIdentity("", x.NewUUID().String()) + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: old1Email, Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + {Value: old2Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + {Value: old3Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusSent}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.VerifiableAddresses, 3) + + new1Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + new2Email := "dev+" + uuid.Must(uuid.NewV4()).String() + "+@ory.com" + // Replace all addresses with new ones + updated := fromDB.CopyWithoutCredentials() + updated.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: new1Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + {Value: new2Email, Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + } + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.VerifiableAddresses, 2) + + values := []string{actual.VerifiableAddresses[0].Value, actual.VerifiableAddresses[1].Value} + assertContainsValues(t, values, []string{new1Email, new2Email}, []string{old1Email, old2Email, old3Email}) + }) + }) + + t.Run("suite=update-recovery-addresses-edge-cases", func(t *testing.T) { + t.Run("case=add new recovery addresses", func(t *testing.T) { + initialEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + recovery1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + recovery2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + + initial := passwordIdentity("", x.NewUUID().String()) + initial.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: initialEmail, Via: identity.AddressTypeEmail}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.RecoveryAddresses, 1) + + // Add two new addresses + updated := fromDB.CopyWithoutCredentials() + updated.RecoveryAddresses = append(updated.RecoveryAddresses, + identity.RecoveryAddress{Value: recovery1Email, Via: identity.AddressTypeEmail}, + identity.RecoveryAddress{Value: recovery2Email, Via: identity.AddressTypeEmail}, + ) + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.RecoveryAddresses, 3) + + values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value, actual.RecoveryAddresses[2].Value} + assertContainsValues(t, values, []string{initialEmail, recovery1Email, recovery2Email}, nil) + }) + + t.Run("case=remove all recovery addresses", func(t *testing.T) { + remove1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + remove2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + + initial := passwordIdentity("", x.NewUUID().String()) + initial.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: remove1Email, Via: identity.AddressTypeEmail}, + {Value: remove2Email, Via: identity.AddressTypeEmail}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.RecoveryAddresses, 2) + + // Remove all addresses + updated := fromDB.CopyWithoutCredentials() + updated.RecoveryAddresses = []identity.RecoveryAddress{} + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + assert.Len(t, actual.RecoveryAddresses, 0) + }) + + t.Run("case=remove some and add some recovery addresses", func(t *testing.T) { + keepEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + removeEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + addEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + + initial := passwordIdentity("", x.NewUUID().String()) + initial.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: keepEmail, Via: identity.AddressTypeEmail}, + {Value: removeEmail, Via: identity.AddressTypeEmail}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.RecoveryAddresses, 2) + + // Keep one, remove one, add one + updated := fromDB.CopyWithoutCredentials() + var keptAddress identity.RecoveryAddress + for _, addr := range updated.RecoveryAddresses { + if addr.Value == keepEmail { + keptAddress = addr + break + } + } + updated.RecoveryAddresses = []identity.RecoveryAddress{ + keptAddress, + {Value: addEmail, Via: identity.AddressTypeEmail}, + } + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.RecoveryAddresses, 2) + + values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value} + assertContainsValues(t, values, []string{keepEmail, addEmail}, []string{removeEmail}) + }) + + t.Run("case=replace all recovery addresses at once", func(t *testing.T) { + old1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + old2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + old3Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + new1Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + new2Email := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + + initial := passwordIdentity("", x.NewUUID().String()) + initial.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: old1Email, Via: identity.AddressTypeEmail}, + {Value: old2Email, Via: identity.AddressTypeEmail}, + {Value: old3Email, Via: identity.AddressTypeEmail}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, fromDB.RecoveryAddresses, 3) + + // Replace all addresses with new ones + updated := fromDB.CopyWithoutCredentials() + updated.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: new1Email, Via: identity.AddressTypeEmail}, + {Value: new2Email, Via: identity.AddressTypeEmail}, + } + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + require.Len(t, actual.RecoveryAddresses, 2) + + values := []string{actual.RecoveryAddresses[0].Value, actual.RecoveryAddresses[1].Value} + assertContainsValues(t, values, []string{new1Email, new2Email}, []string{old1Email, old2Email, old3Email}) + }) + }) + + t.Run("suite=update-credentials-edge-cases", func(t *testing.T) { + t.Run("case=add new credential type", func(t *testing.T) { + totpIdentifier := randx.MustString(16, randx.AlphaLowerNum) + + initial := passwordIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 1) + _, hasPassword := fromDB.Credentials[identity.CredentialsTypePassword] + assert.True(t, hasPassword) + oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID + + // Add TOTP credential + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{totpIdentifier}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 2) + _, hasPassword = actual.Credentials[identity.CredentialsTypePassword] + _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] + assert.True(t, hasPassword) + assert.True(t, hasTOTP) + assert.Equal(t, []string{totpIdentifier}, actual.Credentials[identity.CredentialsTypeTOTP].Identifiers) + // Verify that the password credential was not recreated (ID should remain the same) + assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when adding TOTP") + }) + + t.Run("case=remove all credentials", func(t *testing.T) { + oidcIdentifier := randx.MustString(16, randx.AlphaLowerNum) + + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{oidcIdentifier}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 2) + + // Remove all credentials + initial.Credentials = map[identity.CredentialsType]identity.Credentials{} + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + assert.Len(t, actual.Credentials, 0) + }) + + t.Run("case=remove one credential type and keep others", func(t *testing.T) { + oidcIdentifier := randx.MustString(16, randx.AlphaLowerNum) + totpIdentifier := randx.MustString(16, randx.AlphaLowerNum) + + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{oidcIdentifier}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{totpIdentifier}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 3) + oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID + oldTOTPCredID := fromDB.Credentials[identity.CredentialsTypeTOTP].ID + + // Remove password credential, keep OIDC and TOTP + delete(initial.Credentials, identity.CredentialsTypePassword) + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 2) + _, hasPassword := actual.Credentials[identity.CredentialsTypePassword] + _, hasOIDC := actual.Credentials[identity.CredentialsTypeOIDC] + _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] + assert.False(t, hasPassword) + assert.True(t, hasOIDC) + assert.True(t, hasTOTP) + // Verify that OIDC and TOTP credentials were not recreated (IDs should remain the same) + assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when removing password") + assert.Equal(t, oldTOTPCredID, actual.Credentials[identity.CredentialsTypeTOTP].ID, "TOTP credential should not be recreated when removing password") + }) + + t.Run("case=update credential config and identifiers", func(t *testing.T) { + oldEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + newEmail := randx.MustString(16, randx.AlphaLowerNum) + "@ory.sh" + initial := passwordIdentity("", oldEmail) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + oldCred := fromDB.Credentials[identity.CredentialsTypePassword] + + // Update password credential with new identifier and config + initial.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{newEmail}, + Config: sqlxx.JSONRawMessage(`{"new":"config"}`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + newCred := actual.Credentials[identity.CredentialsTypePassword] + assert.NotEqual(t, oldCred.ID, newCred.ID) + assert.Equal(t, []string{newEmail}, newCred.Identifiers) + assert.JSONEq(t, `{"new":"config"}`, string(newCred.Config)) + }) + + t.Run("case=replace all credentials at once", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-replace"}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"totp-replace"}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 3) + + // Replace all credentials with webauthn + initial.Credentials = map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypeWebAuthn: { + Type: identity.CredentialsTypeWebAuthn, + Identifiers: []string{"webauthn-new"}, + Config: sqlxx.JSONRawMessage(`{"credentials":[]}`), + }, + } + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 1) + _, hasWebAuthn := actual.Credentials[identity.CredentialsTypeWebAuthn] + assert.True(t, hasWebAuthn) + assert.Equal(t, []string{"webauthn-new"}, actual.Credentials[identity.CredentialsTypeWebAuthn].Identifiers) + }) + + t.Run("case=update with no changes", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-no-change"}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"totp-no-change"}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 3) + oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID + oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID + oldTOTPCredID := fromDB.Credentials[identity.CredentialsTypeTOTP].ID + + // Update without changing anything + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + // Verify no credentials were added or removed + require.Len(t, actual.Credentials, 3, "credential count should not change when nothing is modified") + // Verify all credential IDs remained the same (nothing was recreated) + assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when nothing changes") + assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when nothing changes") + assert.Equal(t, oldTOTPCredID, actual.Credentials[identity.CredentialsTypeTOTP].ID, "TOTP credential should not be recreated when nothing changes") + }) + + t.Run("case=update with json whitespace differences", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + // Create with compact JSON + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-whitespace"}, + Config: sqlxx.JSONRawMessage(`{"foo":"bar","baz":"qux"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 2) + oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID + oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID + + // Update with same JSON but different whitespace formatting + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-whitespace"}, + // Same JSON content but with different whitespace + Config: sqlxx.JSONRawMessage(`{ + "foo": "bar", + "baz": "qux" + }`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, initial, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + // Verify no credentials were added or removed + require.Len(t, actual.Credentials, 2, "credential count should not change") + // Verify credential IDs remained the same (nothing was recreated despite JSON formatting difference) + assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated") + assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when JSON has different whitespace") + }) + + t.Run("case=update traits with fromDatabase parameter", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.Traits = identity.Traits(`{"email":"initial@ory.sh","name":"Initial Name"}`) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + + // Update traits using DiffAgainst + updated := fromDB.CopyWithoutCredentials() + updated.Traits = identity.Traits(`{"email":"updated@ory.sh","name":"Updated Name"}`) + + require.NoError(t, p.UpdateIdentity(ctx, updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentity(ctx, initial.ID, identity.ExpandDefault) + require.NoError(t, err) + assert.JSONEq(t, `{"email":"updated@ory.sh","name":"Updated Name"}`, string(actual.Traits)) + }) + + t.Run("case=update without fromDatabase parameter", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-no-from-db"}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 2) + oldPasswordCredID := fromDB.Credentials[identity.CredentialsTypePassword].ID + oldOIDCCredID := fromDB.Credentials[identity.CredentialsTypeOIDC].ID + + // Update without providing fromDatabase - should fetch from DB internally + updated := *fromDB + updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"totp-no-from-db"}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, &updated)) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 3) + _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] + assert.True(t, hasTOTP) + // Verify that password and OIDC credentials were not recreated (IDs should remain the same) + assert.Equal(t, oldPasswordCredID, actual.Credentials[identity.CredentialsTypePassword].ID, "password credential should not be recreated when adding TOTP without fromDatabase") + assert.Equal(t, oldOIDCCredID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "OIDC credential should not be recreated when adding TOTP without fromDatabase") + }) + + t.Run("case=WithoutCredentialTypes leaves the excluded credential untouched", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"totp-excluded-" + x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/old"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 2) + dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] + dbTOTPCred := fromDB.Credentials[identity.CredentialsTypeTOTP] + + // The in-memory password config diverges from the database, as if a + // concurrent UpdateCredentialsConfig had won. Password is excluded, + // so its row must survive while TOTP still updates. + updated := *fromDB + updated.Credentials = nil + updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: dbPasswordCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"foo":"stale-in-memory"}`), + Version: dbPasswordCred.Version, + }) + updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: dbTOTPCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/new"}`), + }) + updated.Traits = identity.Traits(`{"email":"excluded-update@ory.sh"}`) + + require.NoError(t, p.UpdateIdentity(ctx, &updated, + identity.DiffAgainst(fromDB), + identity.WithoutCredentialTypes(identity.CredentialsTypePassword))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 2) + // The excluded row is untouched: same row ID, same config. + assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded credential must not be recreated") + assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded credential config must not change") + // The non-excluded credential still updates. + assert.JSONEq(t, `{"totp_url":"otpauth://totp/new"}`, string(actual.Credentials[identity.CredentialsTypeTOTP].Config)) + // Identity-level changes still persist. + assert.JSONEq(t, `{"email":"excluded-update@ory.sh"}`, string(actual.Traits)) + // The returned identity carries the database state of the excluded + // type, not the stale in-memory copy. + assert.Equal(t, dbPasswordCred.ID, updated.Credentials[identity.CredentialsTypePassword].ID) + assert.JSONEq(t, string(dbPasswordCred.Config), string(updated.Credentials[identity.CredentialsTypePassword].Config)) + }) + + t.Run("case=WithoutCredentialTypes without DiffAgainst leaves the excluded credential untouched", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] + + updated := *fromDB + updated.Credentials = nil + updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: dbPasswordCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"foo":"stale-in-memory"}`), + Version: dbPasswordCred.Version, + }) + + require.NoError(t, p.UpdateIdentity(ctx, &updated, + identity.WithoutCredentialTypes(identity.CredentialsTypePassword))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 1) + assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded credential must not be recreated") + assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded credential config must not change") + // The returned identity carries the database state of the excluded type. + assert.JSONEq(t, string(dbPasswordCred.Config), string(updated.Credentials[identity.CredentialsTypePassword].Config)) + }) + + t.Run("case=WithoutCredentialTypes excludes two credential types at once", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: []string{"oidc-two-excluded-" + x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + initial.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"totp-two-excluded-" + x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/old"}`), + }) + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, fromDB.Credentials, 3) + dbPasswordCred := fromDB.Credentials[identity.CredentialsTypePassword] + dbOIDCCred := fromDB.Credentials[identity.CredentialsTypeOIDC] + dbTOTPCred := fromDB.Credentials[identity.CredentialsTypeTOTP] + + // Both excluded types diverge in memory; both rows must survive + // while the non-excluded TOTP credential still updates. + updated := *fromDB + updated.Credentials = nil + updated.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: dbPasswordCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"foo":"stale-password"}`), + Version: dbPasswordCred.Version, + }) + updated.SetCredentials(identity.CredentialsTypeOIDC, identity.Credentials{ + Type: identity.CredentialsTypeOIDC, + Identifiers: dbOIDCCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"foo":"stale-oidc"}`), + Version: dbOIDCCred.Version, + }) + updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: dbTOTPCred.Identifiers, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/new"}`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, &updated, + identity.DiffAgainst(fromDB), + identity.WithoutCredentialTypes(identity.CredentialsTypePassword, identity.CredentialsTypeOIDC))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.Credentials, 3) + // Both excluded rows are untouched: same row IDs, same configs. + assert.Equal(t, dbPasswordCred.ID, actual.Credentials[identity.CredentialsTypePassword].ID, "excluded password credential must not be recreated") + assert.JSONEq(t, string(dbPasswordCred.Config), string(actual.Credentials[identity.CredentialsTypePassword].Config), "excluded password config must not change") + assert.Equal(t, dbOIDCCred.ID, actual.Credentials[identity.CredentialsTypeOIDC].ID, "excluded OIDC credential must not be recreated") + assert.JSONEq(t, string(dbOIDCCred.Config), string(actual.Credentials[identity.CredentialsTypeOIDC].Config), "excluded OIDC config must not change") + // The non-excluded credential still updates. + assert.JSONEq(t, `{"totp_url":"otpauth://totp/new"}`, string(actual.Credentials[identity.CredentialsTypeTOTP].Config)) + }) + }) + + t.Run("suite=update-combined-changes", func(t *testing.T) { + t.Run("case=update addresses and credentials simultaneously", func(t *testing.T) { + initial := passwordIdentity("", x.NewUUID().String()) + initial.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: "combined-verify@ory.sh", Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending}, + } + initial.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: "combined-recovery@ory.sh", Via: identity.AddressTypeEmail}, + } + require.NoError(t, p.CreateIdentity(ctx, initial)) + createdIDs = append(createdIDs, initial.ID) + + fromDB, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + + // Change everything at once + updated := *fromDB + updated.VerifiableAddresses = []identity.VerifiableAddress{ + {Value: "combined-verify-new@ory.sh", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted}, + } + updated.RecoveryAddresses = []identity.RecoveryAddress{ + {Value: "combined-recovery-new@ory.sh", Via: identity.AddressTypeEmail}, + } + updated.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{ + Type: identity.CredentialsTypeTOTP, + Identifiers: []string{"combined-totp"}, + Config: sqlxx.JSONRawMessage(`{"totp_url":"otpauth://totp/test"}`), + }) + + require.NoError(t, p.UpdateIdentity(ctx, &updated, identity.DiffAgainst(fromDB))) + + actual, err := p.GetIdentityConfidential(ctx, initial.ID) + require.NoError(t, err) + require.Len(t, actual.VerifiableAddresses, 1) + require.Len(t, actual.RecoveryAddresses, 1) + require.Len(t, actual.Credentials, 2) + + assert.Equal(t, "combined-verify-new@ory.sh", actual.VerifiableAddresses[0].Value) + assert.Equal(t, "combined-recovery-new@ory.sh", actual.RecoveryAddresses[0].Value) + _, hasTOTP := actual.Credentials[identity.CredentialsTypeTOTP] + assert.True(t, hasTOTP) + }) + }) + + t.Run("case=UpdateCredentialsConfig does not lose concurrent increments", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"cas-key-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + created, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + createdCreds, ok := created.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + versionBefore := createdCreds.Version + + // Fire N concurrent read-modify-write increments; the final counter + // must equal exactly N. SQLite serializes writes, so real contention + // is exercised on the concurrent engines. + const N = 12 + var wg sync.WaitGroup + for range N { + wg.Go(func() { + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + var parsed map[string]int + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + parsed["failed_attempts"]++ + return json.Marshal(parsed) + }) + // Assert in a goroutine without FailNow to stay concurrency-safe. + assert.NoError(t, err) + }) + } + wg.Wait() + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + var counts map[string]int + require.NoError(t, json.Unmarshal(c.Config, &counts)) + assert.Equal(t, N, counts["failed_attempts"], "no increment may be lost") + // The version column versions the *shape* of the config (its schema), + // not its content. Content updates must not consume it, or future + // schema migrations keyed on version become impossible. + assert.Equal(t, versionBefore, c.Version, "content updates must not bump the config-schema version column") + }) + + t.Run("case=UpdateCredentialsConfig survives concurrent plain writes", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"plain-writes-key-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + // Plain single-statement writes commit freely while the locked + // mutation's transaction is open: pop's SQLite transaction mutex + // does not cover them, and on the cluster databases they contend + // only on row locks. They model the flow and session writes that + // surround a login burst. + stop := make(chan struct{}) + var noise sync.WaitGroup + var noiseWrites atomic.Int64 + for w := range 3 { + noise.Go(func() { + // Bind a value that changes on every write: SQLite skips the + // page write (and thus the WAL commit) when an UPDATE leaves + // the row byte-identical, and a commit that appends nothing + // stales nobody's snapshot. + ts := time.Now().Add(time.Duration(w) * time.Hour) + for { + select { + case <-stop: + return + default: + } + ts = ts.Add(time.Second) + if err := p.GetConnection(ctx).RawQuery( + "UPDATE identities SET updated_at = ? WHERE id = ?", ts, id.ID).Exec(); err != nil { + return + } + noiseWrites.Add(1) + } + }) + } + t.Cleanup(func() { + close(stop) + noise.Wait() + require.NotZero(t, noiseWrites.Load(), "the concurrent writer must actually have written") + }) + + const n = 10 + for i := range n { + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + var parsed map[string]int + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + parsed["failed_attempts"]++ + // Give the concurrent writers room to commit between this + // transaction's read and its write, so the race is a + // certainty instead of a scheduling accident. + time.Sleep(10 * time.Millisecond) + return json.Marshal(parsed) + }), "mutation %d must not leak a concurrent-update error", i) + } + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + var counts map[string]int + require.NoError(t, json.Unmarshal(c.Config, &counts)) + assert.Equal(t, n, counts["failed_attempts"], "every increment must be applied exactly once") + }) + + t.Run("case=UpdateCredentialsConfig no-op mutation succeeds", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"cas-noop-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + // A mutation that leaves the config unchanged must succeed without + // writing. + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { return cfg, nil })) + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + var counts map[string]int + require.NoError(t, json.Unmarshal(c.Config, &counts)) + assert.Equal(t, 0, counts["failed_attempts"], "config must be unchanged") + }) + + t.Run("case=UpdateCredentialsConfig serializes the whole read-mutate-write", func(t *testing.T) { + t.Parallel() + + if dbname == "sqlite" { + // SQLite serializes writers at the engine level; the interleaving + // this test forces cannot occur there. + t.Skip("SQLite cannot interleave two writers") + } + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"lock-serialization-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + // Writer A parks inside its mutate callback; writer B starts only once + // A is inside. Under an exclusive row lock B cannot run until A + // commits, so B must observe A's increment. Asserting on the value B + // reads (not on wall-clock ordering) is causally determined by the + // locking and immune to goroutine scheduling. + var aInsideOnce, bInsideOnce sync.Once + aInside := make(chan struct{}) + bInside := make(chan struct{}) + releaseA := make(chan struct{}) + + increment := func(cfg []byte) (map[string]int, error) { + var parsed map[string]int + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + parsed["failed_attempts"]++ + return parsed, nil + } + + var wg sync.WaitGroup + wg.Go(func() { + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + // The callback may re-run on database retries, so the + // signal fires once and the park is re-entrant. + aInsideOnce.Do(func() { close(aInside) }) + <-releaseA + parsed, err := increment(cfg) + if err != nil { + return nil, err + } + return json.Marshal(parsed) + }) + assert.NoError(t, err) + }) + + <-aInside + + // bSaw is written only from B's callback and read after wg.Wait. + bSaw := -1 + wg.Go(func() { + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + bInsideOnce.Do(func() { close(bInside) }) + parsed, err := increment(cfg) + if err != nil { + return nil, err + } + bSaw = parsed["failed_attempts"] - 1 + return json.Marshal(parsed) + }) + assert.NoError(t, err) + }) + + // Give B time to reach the row before releasing A; if B's callback + // runs during this window, the value assertion below fails. + select { + case <-bInside: + case <-time.After(500 * time.Millisecond): + } + close(releaseA) + wg.Wait() + + assert.Equal(t, 1, bSaw, + "B's mutate must observe A's committed increment; observing the pre-A value means the read-mutate-write cycle is not exclusively locked") + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + var counts map[string]int + require.NoError(t, json.Unmarshal(c.Config, &counts)) + assert.Equal(t, 2, counts["failed_attempts"], "both increments must be committed") + }) + + t.Run("case=UpdateCredentialsConfig runs at READ COMMITTED", func(t *testing.T) { + t.Parallel() + + // UpdateCredentialsConfig depends on READ COMMITTED so that its + // SELECT ... FOR UPDATE is a durable row lock on CockroachDB rather + // than the best-effort lock SERIALIZABLE gives. This guards that the + // isolation request is actually honored — not silently upgraded by + // the cluster or dropped by a pop/driver change — because under + // SERIALIZABLE the lock, and therefore the lost-update protection, + // would quietly weaken. + // + // Only PostgreSQL and CockroachDB are asserted here. SQLite has no + // configurable isolation and serializes writers at the engine level. + // MySQL's default REPEATABLE READ already takes durable FOR UPDATE + // locks (so RC is not needed for correctness there), and its + // per-transaction isolation override does not update + // @@transaction_isolation, so it cannot be observed with a query. + if dbname != "postgres" && dbname != "cockroach" { + t.Skipf("transaction isolation is not observable / not required on %s", dbname) + } + + var got string + require.NoError(t, popx.TransactionWithOptions(ctx, p.GetConnection(ctx), + &sql.TxOptions{Isolation: sql.LevelReadCommitted}, + func(ctx context.Context, tx *pop.Connection) error { + var row struct { + Level string `db:"transaction_isolation"` + } + if err := tx.RawQuery("SHOW transaction_isolation").First(&row); err != nil { + return err + } + got = row.Level + return nil + })) + assert.Equal(t, "read committed", got) + }) + + t.Run("case=UpdateCredentialsConfig bounds the wait for the row lock", func(t *testing.T) { + t.Parallel() + + if dbname == "sqlite" { + t.Skip("SQLite has no row-lock wait to bound") + } + if testing.Short() { + t.Skip("holds a row lock for longer than the lock timeout") + } + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"lock-timeout-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + // Writer A parks inside its mutate callback holding the row lock. The + // whole transaction runs under a bounded context, so neither the + // holder nor a waiter can occupy a pooled connection beyond the + // budget: A's transaction is rolled back when its budget expires, and + // B — whether it errors on its own deadline or acquires the lock + // freed by A's rollback — must return within the generous ceiling + // (5x the 5s budget) instead of parking for an engine default (e.g. + // MySQL's 50s innodb_lock_wait_timeout). Neither writer's outcome is + // asserted: A deliberately overstays its budget, and B's result + // depends on whose deadline fires first. + var aInsideOnce sync.Once + aInside := make(chan struct{}) + releaseA := make(chan struct{}) + + var wg sync.WaitGroup + wg.Go(func() { + _ = p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + aInsideOnce.Do(func() { close(aInside) }) + <-releaseA + return cfg, nil + }) + }) + <-aInside + + start := time.Now() + _ = p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { return cfg, nil }) + elapsed := time.Since(start) + close(releaseA) + wg.Wait() + + assert.Less(t, elapsed, 25*time.Second, "the wait must be bounded near the configured lock timeout, not the engine default") + }) + + t.Run("case=UpdateCredentialsConfig refuses to run inside a surrounding transaction", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"lock-ambient-tx-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + + // An ambient transaction would extend the lock and its timeout to the + // outer transaction's lifetime; the persister must fail loudly instead. + mutated := false + err := p.Transaction(ctx, func(ctx context.Context, _ *pop.Connection) error { + return p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + mutated = true + return cfg, nil + }) + }) + require.Error(t, err, "calling UpdateCredentialsConfig inside a transaction must be rejected") + assert.False(t, mutated, "mutate must not run when the call is rejected") + }) + + t.Run("case=UpdateConfig typed adapter", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + + // The adapter is credential-type agnostic; this stand-in models a + // complete stored config shape (like a lockout or clone counter). + type counterConfig struct { + FailedAttempts int `json:"failed_attempts"` + } + + newCounterIdentity := func(t *testing.T, rawConfig string) *identity.Identity { + t.Helper() + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{x.NewUUID().String()}, + Config: sqlxx.JSONRawMessage(rawConfig), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + return id + } + + t.Run("decodes, mutates, and persists", func(t *testing.T) { + t.Parallel() + + id := newCounterIdentity(t, `{"failed_attempts":41}`) + + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + identity.UpdateConfig(func(c *counterConfig) error { + c.FailedAttempts++ + return nil + }))) + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + assert.Equal(t, int64(42), gjson.GetBytes(c.Config, "failed_attempts").Int()) + }) + + t.Run("pins that fields T does not model are dropped", func(t *testing.T) { + t.Parallel() + + // Pins the documented contract: T must model the COMPLETE stored + // shape, because a partial T silently drops sibling fields. + id := newCounterIdentity(t, `{"failed_attempts":5,"sibling":"not-modeled-by-T"}`) + + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + identity.UpdateConfig(func(c *counterConfig) error { + c.FailedAttempts++ + return nil + }))) + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + assert.Equal(t, int64(6), gjson.GetBytes(c.Config, "failed_attempts").Int()) + assert.False(t, gjson.GetBytes(c.Config, "sibling").Exists(), + "a field T does not model is dropped by the roundtrip — T must model the complete stored shape") + }) + + t.Run("null config is rejected without calling mutate", func(t *testing.T) { + t.Parallel() + + // Mutating a zero value would silently replace whatever the row + // held. JSON null is the one undecodable state every dialect can + // store (JSON-typed columns refuse empty/whitespace outright). + id := newCounterIdentity(t, "null") + + mutated := false + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + identity.UpdateConfig(func(c *counterConfig) error { + mutated = true + return nil + })) + require.Error(t, err) + assert.False(t, mutated, "mutate must not run on an undecodable config") + }) + + t.Run("mutate error aborts without persisting", func(t *testing.T) { + t.Parallel() + + id := newCounterIdentity(t, `{"failed_attempts":7}`) + + bang := errors.New("mutation rejected") + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + identity.UpdateConfig(func(c *counterConfig) error { + c.FailedAttempts = 999 + return bang + })) + require.ErrorIs(t, err, bang) + + reloaded, err := p.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + assert.Equal(t, int64(7), gjson.GetBytes(c.Config, "failed_attempts").Int(), "a failed mutation must not be persisted") + }) + }) + + t.Run("case=UpdateCredentialsConfig not if on another network", func(t *testing.T) { + t.Parallel() + + // Create the identity (and its credential row) on network A. + _, pA := testhelpers.NewNetwork(t, ctx, p) + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: []string{"cas-nid-isolation-1"}, + Config: sqlxx.JSONRawMessage(`{"failed_attempts":0}`), + }) + require.NoError(t, pA.CreateIdentity(ctx, id)) + + // A persister scoped to a different network must not see the row. + // The locked SELECT is keyed by nid, so it returns not-found and never + // invokes mutate, guarding against a cross-tenant mutation. + _, pB := testhelpers.NewNetwork(t, ctx, p) + mutated := false + err := pB.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { + mutated = true + return []byte(`{"failed_attempts":999}`), nil + }) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + assert.False(t, mutated, "mutate must not run for an identity on another network") + + // The original row on network A must be untouched. + reloaded, err := pA.GetIdentityConfidential(ctx, id.ID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + var counts map[string]int + require.NoError(t, json.Unmarshal(c.Config, &counts)) + assert.Equal(t, 0, counts["failed_attempts"], "row must not be mutated across networks") + }) + + t.Run("case=UpdateCredentialsConfig syncs derived identifiers", func(t *testing.T) { + t.Parallel() + + // The stand-in config stores a set of keys; the identifier rows are + // pure derived state of that set. + deriveKeys := func(cfg []byte) ([]string, error) { + var parsed struct { + Keys []string `json:"keys"` + } + if err := json.Unmarshal(cfg, &parsed); err != nil { + return nil, err + } + return parsed.Keys, nil + } + + setKeys := func(keys ...string) func(cfg []byte) ([]byte, error) { + return func([]byte) ([]byte, error) { + return json.Marshal(map[string][]string{"keys": keys}) + } + } + + newKeyedIdentity := func(t *testing.T, p persistence.Persister, identifiers ...string) *identity.Identity { + t.Helper() + cfg, err := json.Marshal(map[string][]string{"keys": identifiers}) + require.NoError(t, err) + id := identity.NewIdentity("default") + id.SetCredentials(identity.CredentialsTypeDeviceAuthn, identity.Credentials{ + Type: identity.CredentialsTypeDeviceAuthn, + Identifiers: identifiers, + Config: sqlxx.JSONRawMessage(cfg), + }) + require.NoError(t, p.CreateIdentity(ctx, id)) + return id + } + + credentials := func(t *testing.T, p persistence.Persister, identityID uuid.UUID) identity.Credentials { + t.Helper() + reloaded, err := p.GetIdentityConfidential(ctx, identityID) + require.NoError(t, err) + c, ok := reloaded.GetCredentials(identity.CredentialsTypeDeviceAuthn) + require.True(t, ok) + return *c + } + + // identifierRows reads the credential's raw identifier rows so tests + // can observe row identity (a re-created row gets a new id). + identifierRows := func(t *testing.T, p persistence.Persister, credID uuid.UUID) map[string]uuid.UUID { + t.Helper() + var rows []struct { + ID uuid.UUID `db:"id"` + Identifier string `db:"identifier"` + } + require.NoError(t, p.GetConnection(ctx).RawQuery( + `SELECT id, identifier FROM identity_credential_identifiers WHERE identity_credential_id = ?`, credID).All(&rows)) + out := make(map[string]uuid.UUID, len(rows)) + for _, r := range rows { + out[r.Identifier] = r.ID + } + return out + } + + t.Run("inserts the derived set on first sync", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + id := newKeyedIdentity(t, p, "sync-seed-1") + + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("sync-key-a", "sync-key-b"), + identity.WithDerivedIdentifiers(deriveKeys))) + + c := credentials(t, p, id.ID) + assert.ElementsMatch(t, []string{"sync-key-a", "sync-key-b"}, c.Identifiers, + "identifier rows must reflect the post-mutation config") + assert.ElementsMatch(t, []string{"sync-key-a", "sync-key-b"}, gjsonStrings(c.Config, "keys")) + }) + + t.Run("removing a key removes its identifier row", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + id := newKeyedIdentity(t, p, "rm-key-a", "rm-key-b") + + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("rm-key-a"), + identity.WithDerivedIdentifiers(deriveKeys))) + + c := credentials(t, p, id.ID) + assert.ElementsMatch(t, []string{"rm-key-a"}, c.Identifiers, + "the deleted key's identifier row must be gone") + }) + + t.Run("no-op config with unchanged identifiers writes nothing", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + id := newKeyedIdentity(t, p, "noop-key-1") + before := identifierRows(t, p, credentials(t, p, id.ID).ID) + + require.NoError(t, p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + func(cfg []byte) ([]byte, error) { return cfg, nil }, + identity.WithDerivedIdentifiers(deriveKeys))) + + c := credentials(t, p, id.ID) + assert.ElementsMatch(t, []string{"noop-key-1"}, c.Identifiers) + assert.Equal(t, before, identifierRows(t, p, c.ID), + "an unchanged identifier set must not re-create the rows") + }) + + t.Run("identifier rows are nid-scoped", func(t *testing.T) { + t.Parallel() + + _, pA := testhelpers.NewNetwork(t, ctx, p) + idA := newKeyedIdentity(t, pA, "nid-scope-key") + rowsA := identifierRows(t, pA, credentials(t, pA, idA.ID).ID) + + // A persister on another network must not see A's credential row; + // neither mutate nor derive may run. + _, pB := testhelpers.NewNetwork(t, ctx, p) + derived := false + err := pB.UpdateCredentialsConfig(ctx, idA.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("nid-scope-clobber"), + identity.WithDerivedIdentifiers(func(cfg []byte) ([]string, error) { + derived = true + return deriveKeys(cfg) + })) + require.ErrorIs(t, err, sqlcon.ErrNoRows()) + assert.False(t, derived, "derive must not run for an identity on another network") + + // The identifier uniqueness is per network: B may claim the same + // value A holds. + idB := newKeyedIdentity(t, pB, "nid-scope-seed") + require.NoError(t, pB.UpdateCredentialsConfig(ctx, idB.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("nid-scope-key"), + identity.WithDerivedIdentifiers(deriveKeys))) + assert.ElementsMatch(t, []string{"nid-scope-key"}, credentials(t, pB, idB.ID).Identifiers) + + // A's rows must be untouched, down to the row ids. + assert.Equal(t, rowsA, identifierRows(t, pA, credentials(t, pA, idA.ID).ID), + "another network's sync must not clobber the rows") + }) + + t.Run("duplicate identifier on another identity fails with unique violation", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + newKeyedIdentity(t, p, "dup-owned") + victim := newKeyedIdentity(t, p, "dup-seed") + + err := p.UpdateCredentialsConfig(ctx, victim.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("dup-owned"), + identity.WithDerivedIdentifiers(deriveKeys)) + require.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) + + // The transaction must roll back as a whole: neither the config + // nor the identifier rows may change. + c := credentials(t, p, victim.ID) + assert.ElementsMatch(t, []string{"dup-seed"}, gjsonStrings(c.Config, "keys")) + assert.ElementsMatch(t, []string{"dup-seed"}, c.Identifiers) + }) + + t.Run("derive error aborts and rolls back the config write", func(t *testing.T) { + t.Parallel() + + _, p := testhelpers.NewNetwork(t, ctx, p) + id := newKeyedIdentity(t, p, "derive-err-seed") + before := identifierRows(t, p, credentials(t, p, id.ID).ID) + + bang := errors.New("derive failed") + err := p.UpdateCredentialsConfig(ctx, id.ID, identity.CredentialsTypeDeviceAuthn, + setKeys("derive-err-new"), + identity.WithDerivedIdentifiers(func([]byte) ([]string, error) { + return nil, bang + })) + require.ErrorIs(t, err, bang, "the derive error must surface to the caller") + + // The transaction must roll back as a whole: the config write + // that already executed is undone and the identifier rows are + // untouched, down to the row ids. + c := credentials(t, p, id.ID) + assert.ElementsMatch(t, []string{"derive-err-seed"}, gjsonStrings(c.Config, "keys"), + "the config write must roll back with the derive error") + assert.ElementsMatch(t, []string{"derive-err-seed"}, c.Identifiers) + assert.Equal(t, before, identifierRows(t, p, c.ID)) + }) + }) + } +} + +// gjsonStrings returns the string array at path in the JSON document. +func gjsonStrings(doc []byte, path string) []string { + var out []string + for _, v := range gjson.GetBytes(doc, path).Array() { + out = append(out, v.String()) + } + return out +} + +func NewTestIdentity(numAddresses int, prefix string, i int) *identity.Identity { + var ( + verifiableAddresses []identity.VerifiableAddress + recoveryAddresses []identity.RecoveryAddress + ) + traits := struct { + Emails []string `json:"emails"` + Username string `json:"username"` + }{} + + verificationStates := []identity.VerifiableAddressStatus{ + identity.VerifiableAddressStatusPending, + identity.VerifiableAddressStatusSent, + identity.VerifiableAddressStatusCompleted, + } + + for j := 0; j < numAddresses; j++ { + email := fmt.Sprintf("%s-%d-%d@ory.sh", prefix, i, j) + traits.Emails = append(traits.Emails, email) + verifiableAddresses = append(verifiableAddresses, identity.VerifiableAddress{ + Value: email, + Via: identity.AddressTypeEmail, + Verified: j%2 == 0, + Status: verificationStates[j%len(verificationStates)], + }) + recoveryAddresses = append(recoveryAddresses, identity.RecoveryAddress{ + Value: email, + Via: identity.AddressTypeEmail, + }) + } + traits.Username = traits.Emails[0] + rawTraits, _ := json.Marshal(traits) + + id := &identity.Identity{ + SchemaID: "multiple_emails", + Traits: rawTraits, + VerifiableAddresses: verifiableAddresses, + RecoveryAddresses: recoveryAddresses, + State: "active", + } + id.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{traits.Username}, + Config: sqlxx.JSONRawMessage(`{}`), + }) + + return id +} diff --git a/pkg/client-go/api_frontend.go b/pkg/client-go/api_frontend.go new file mode 100644 index 0000000..f7fa7d0 --- /dev/null +++ b/pkg/client-go/api_frontend.go @@ -0,0 +1,6729 @@ +/* +Ory Identities API + +This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + +API version: +Contact: office@ory.sh +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strings" +) + +type FrontendAPI interface { + + /* + CreateBrowserLoginFlow Create Login Flow for Browsers + + This endpoint initializes a browser-based user login flow. This endpoint will set the appropriate + cookies and anti-CSRF measures required for browser-based flows. + + If this endpoint is opened as a link in the browser, it will be redirected to + `selfservice.flows.login.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session + exists already, the browser will be redirected to `urls.default_redirect_url` unless the query parameter + `?refresh=true` was set. + + If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the + case of an error, the `error.id` of the JSON response body can be one of: + + `session_already_available`: The user is already signed in. + `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet. + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration! + + The optional query parameter login_challenge is set when using Kratos with + Hydra in an OAuth2 flow. See the oauth2_provider.url configuration + option. + + This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed. + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserLoginFlowRequest + */ + CreateBrowserLoginFlow(ctx context.Context) FrontendAPICreateBrowserLoginFlowRequest + + // CreateBrowserLoginFlowExecute executes the request + // @return LoginFlow + CreateBrowserLoginFlowExecute(r FrontendAPICreateBrowserLoginFlowRequest) (*LoginFlow, *http.Response, error) + + /* + CreateBrowserLogoutFlow Create a Logout URL for Browsers + + This endpoint initializes a browser-based user logout flow and a URL which can be used to log out the user. + + This endpoint is NOT INTENDED for API clients and only works + with browsers (Chrome, Firefox, ...). For API clients you can + call the `/self-service/logout/api` URL directly with the Ory Session Token. + + The URL is only valid for the currently signed in user. If no user is signed in, this endpoint returns + a 401 error. + + When calling this endpoint from a backend, please ensure to properly forward the HTTP cookies. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserLogoutFlowRequest + */ + CreateBrowserLogoutFlow(ctx context.Context) FrontendAPICreateBrowserLogoutFlowRequest + + // CreateBrowserLogoutFlowExecute executes the request + // @return LogoutFlow + CreateBrowserLogoutFlowExecute(r FrontendAPICreateBrowserLogoutFlowRequest) (*LogoutFlow, *http.Response, error) + + /* + CreateBrowserRecoveryFlow Create Recovery Flow for Browsers + + This endpoint initializes a browser-based account recovery flow. Once initialized, the browser will be redirected to + `selfservice.flows.recovery.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session + exists, the browser is returned to the configured return URL. + + If this endpoint is called via an AJAX request, the response contains the recovery flow without any redirects + or a 400 bad request error if the user is already authenticated. + + This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed. + + More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserRecoveryFlowRequest + */ + CreateBrowserRecoveryFlow(ctx context.Context) FrontendAPICreateBrowserRecoveryFlowRequest + + // CreateBrowserRecoveryFlowExecute executes the request + // @return RecoveryFlow + CreateBrowserRecoveryFlowExecute(r FrontendAPICreateBrowserRecoveryFlowRequest) (*RecoveryFlow, *http.Response, error) + + /* + CreateBrowserRegistrationFlow Create Registration Flow for Browsers + + This endpoint initializes a browser-based user registration flow. This endpoint will set the appropriate + cookies and anti-CSRF measures required for browser-based flows. + + If this endpoint is opened as a link in the browser, it will be redirected to + `selfservice.flows.registration.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session + exists already, the browser will be redirected to `urls.default_redirect_url`. + + If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the + case of an error, the `error.id` of the JSON response body can be one of: + + `session_already_available`: The user is already signed in. + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration! + + If this endpoint is called via an AJAX request, the response contains the registration flow without a redirect. + + This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed. + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserRegistrationFlowRequest + */ + CreateBrowserRegistrationFlow(ctx context.Context) FrontendAPICreateBrowserRegistrationFlowRequest + + // CreateBrowserRegistrationFlowExecute executes the request + // @return RegistrationFlow + CreateBrowserRegistrationFlowExecute(r FrontendAPICreateBrowserRegistrationFlowRequest) (*RegistrationFlow, *http.Response, error) + + /* + CreateBrowserSettingsFlow Create Settings Flow for Browsers + + This endpoint initializes a browser-based user settings flow. Once initialized, the browser will be redirected to + `selfservice.flows.settings.ui_url` with the flow ID set as the query parameter `?flow=`. If no valid + Ory Kratos Session Cookie is included in the request, a login flow will be initialized. + + If this endpoint is opened as a link in the browser, it will be redirected to + `selfservice.flows.settings.ui_url` with the flow ID set as the query parameter `?flow=`. If no valid user session + was set, the browser will be redirected to the login endpoint. + + If this endpoint is called via an AJAX request, the response contains the settings flow without any redirects + or a 401 forbidden error if no valid session was set. + + Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator + Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn + credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user + to sign in with the second factor (happens automatically for server-side browser flows) or change the configuration. + + If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the + case of an error, the `error.id` of the JSON response body can be one of: + + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + `session_inactive`: No Ory Session was found - sign in a user first. + `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration! + + This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed. + + More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserSettingsFlowRequest + */ + CreateBrowserSettingsFlow(ctx context.Context) FrontendAPICreateBrowserSettingsFlowRequest + + // CreateBrowserSettingsFlowExecute executes the request + // @return SettingsFlow + CreateBrowserSettingsFlowExecute(r FrontendAPICreateBrowserSettingsFlowRequest) (*SettingsFlow, *http.Response, error) + + /* + CreateBrowserVerificationFlow Create Verification Flow for Browser Clients + + This endpoint initializes a browser-based account verification flow. Once initialized, the browser will be redirected to + `selfservice.flows.verification.ui_url` with the flow ID set as the query parameter `?flow=`. + + If this endpoint is called via an AJAX request, the response contains the recovery flow without any redirects. + + This endpoint is NOT INTENDED for API clients and only works with browsers (Chrome, Firefox, ...). + + More information can be found at [Ory Kratos Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateBrowserVerificationFlowRequest + */ + CreateBrowserVerificationFlow(ctx context.Context) FrontendAPICreateBrowserVerificationFlowRequest + + // CreateBrowserVerificationFlowExecute executes the request + // @return VerificationFlow + CreateBrowserVerificationFlowExecute(r FrontendAPICreateBrowserVerificationFlowRequest) (*VerificationFlow, *http.Response, error) + + /* + CreateFedcmFlow Get FedCM Parameters + + This endpoint returns a list of all available FedCM providers. It is only supported on the Ory Network. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateFedcmFlowRequest + */ + CreateFedcmFlow(ctx context.Context) FrontendAPICreateFedcmFlowRequest + + // CreateFedcmFlowExecute executes the request + // @return CreateFedcmFlowResponse + CreateFedcmFlowExecute(r FrontendAPICreateFedcmFlowRequest) (*CreateFedcmFlowResponse, *http.Response, error) + + /* + CreateNativeLoginFlow Create Login Flow for Native Apps + + This endpoint initiates a login flow for native apps that do not use a browser, such as mobile devices, smart TVs, and so on. + + If a valid provided session cookie or session token is provided, a 400 Bad Request error + will be returned unless the URL query parameter `?refresh=true` is set. + + To fetch an existing login flow call `/self-service/login/flows?flow=`. + + You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server + Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make + you vulnerable to a variety of CSRF attacks, including CSRF login attacks. + + In the case of an error, the `error.id` of the JSON response body can be one of: + + `session_already_available`: The user is already signed in. + `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet. + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + + This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...). + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateNativeLoginFlowRequest + */ + CreateNativeLoginFlow(ctx context.Context) FrontendAPICreateNativeLoginFlowRequest + + // CreateNativeLoginFlowExecute executes the request + // @return LoginFlow + CreateNativeLoginFlowExecute(r FrontendAPICreateNativeLoginFlowRequest) (*LoginFlow, *http.Response, error) + + /* + CreateNativeRecoveryFlow Create Recovery Flow for Native Apps + + This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on. + + If a valid provided session cookie or session token is provided, a 400 Bad Request error. + + On an existing recovery flow, use the `getRecoveryFlow` API endpoint. + + You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server + Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make + you vulnerable to a variety of CSRF attacks. + + This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...). + + More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateNativeRecoveryFlowRequest + */ + CreateNativeRecoveryFlow(ctx context.Context) FrontendAPICreateNativeRecoveryFlowRequest + + // CreateNativeRecoveryFlowExecute executes the request + // @return RecoveryFlow + CreateNativeRecoveryFlowExecute(r FrontendAPICreateNativeRecoveryFlowRequest) (*RecoveryFlow, *http.Response, error) + + /* + CreateNativeRegistrationFlow Create Registration Flow for Native Apps + + This endpoint initiates a registration flow for API clients such as mobile devices, smart TVs, and so on. + + If a valid provided session cookie or session token is provided, a 400 Bad Request error + will be returned unless the URL query parameter `?refresh=true` is set. + + To fetch an existing registration flow call `/self-service/registration/flows?flow=`. + + You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server + Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make + you vulnerable to a variety of CSRF attacks. + + In the case of an error, the `error.id` of the JSON response body can be one of: + + `session_already_available`: The user is already signed in. + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + + This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...). + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateNativeRegistrationFlowRequest + */ + CreateNativeRegistrationFlow(ctx context.Context) FrontendAPICreateNativeRegistrationFlowRequest + + // CreateNativeRegistrationFlowExecute executes the request + // @return RegistrationFlow + CreateNativeRegistrationFlowExecute(r FrontendAPICreateNativeRegistrationFlowRequest) (*RegistrationFlow, *http.Response, error) + + /* + CreateNativeSettingsFlow Create Settings Flow for Native Apps + + This endpoint initiates a settings flow for API clients such as mobile devices, smart TVs, and so on. + You must provide a valid Ory Kratos Session Token for this endpoint to respond with HTTP 200 OK. + + To fetch an existing settings flow call `/self-service/settings/flows?flow=`. + + You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server + Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make + you vulnerable to a variety of CSRF attacks. + + Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator + Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn + credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user + to sign in with the second factor or change the configuration. + + In the case of an error, the `error.id` of the JSON response body can be one of: + + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + `session_inactive`: No Ory Session was found - sign in a user first. + + This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...). + + More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateNativeSettingsFlowRequest + */ + CreateNativeSettingsFlow(ctx context.Context) FrontendAPICreateNativeSettingsFlowRequest + + // CreateNativeSettingsFlowExecute executes the request + // @return SettingsFlow + CreateNativeSettingsFlowExecute(r FrontendAPICreateNativeSettingsFlowRequest) (*SettingsFlow, *http.Response, error) + + /* + CreateNativeVerificationFlow Create Verification Flow for Native Apps + + This endpoint initiates a verification flow for API clients such as mobile devices, smart TVs, and so on. + + To fetch an existing verification flow call `/self-service/verification/flows?flow=`. + + You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server + Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make + you vulnerable to a variety of CSRF attacks. + + This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...). + + More information can be found at [Ory Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPICreateNativeVerificationFlowRequest + */ + CreateNativeVerificationFlow(ctx context.Context) FrontendAPICreateNativeVerificationFlowRequest + + // CreateNativeVerificationFlowExecute executes the request + // @return VerificationFlow + CreateNativeVerificationFlowExecute(r FrontendAPICreateNativeVerificationFlowRequest) (*VerificationFlow, *http.Response, error) + + /* + DeleteTestLoginFlow Delete a test OIDC login flow + + Deletes a dry-run OIDC test login flow. A flow whose debug payload has + been captured requires the HMAC cookie set by the OIDC callback; a flow + still in the initial choose-method state is deletable with just the flow + ID (it carries no PII, and the admin may want to abandon it). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIDeleteTestLoginFlowRequest + */ + DeleteTestLoginFlow(ctx context.Context) FrontendAPIDeleteTestLoginFlowRequest + + // DeleteTestLoginFlowExecute executes the request + DeleteTestLoginFlowExecute(r FrontendAPIDeleteTestLoginFlowRequest) (*http.Response, error) + + /* + DisableMyOtherSessions Disable my other sessions + + Calling this endpoint invalidates all except the current session that belong to the logged-in user. + Session data are not deleted. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIDisableMyOtherSessionsRequest + */ + DisableMyOtherSessions(ctx context.Context) FrontendAPIDisableMyOtherSessionsRequest + + // DisableMyOtherSessionsExecute executes the request + // @return DeleteMySessionsCount + DisableMyOtherSessionsExecute(r FrontendAPIDisableMyOtherSessionsRequest) (*DeleteMySessionsCount, *http.Response, error) + + /* + DisableMySession Disable one of my sessions + + Calling this endpoint invalidates the specified session. The current session cannot be revoked. + Session data are not deleted. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id ID is the session's ID. + @return FrontendAPIDisableMySessionRequest + */ + DisableMySession(ctx context.Context, id string) FrontendAPIDisableMySessionRequest + + // DisableMySessionExecute executes the request + DisableMySessionExecute(r FrontendAPIDisableMySessionRequest) (*http.Response, error) + + /* + ExchangeSessionToken Exchange Session Token + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIExchangeSessionTokenRequest + */ + ExchangeSessionToken(ctx context.Context) FrontendAPIExchangeSessionTokenRequest + + // ExchangeSessionTokenExecute executes the request + // @return SuccessfulNativeLogin + ExchangeSessionTokenExecute(r FrontendAPIExchangeSessionTokenRequest) (*SuccessfulNativeLogin, *http.Response, error) + + /* + GetFlowError Get User-Flow Errors + + This endpoint returns the error associated with a user-facing self service errors. + + This endpoint supports stub values to help you implement the error UI: + + `?id=stub:500` - returns a stub 500 (Internal Server Error) error. + + More information can be found at [Ory Kratos User User Facing Error Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-facing-errors). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetFlowErrorRequest + */ + GetFlowError(ctx context.Context) FrontendAPIGetFlowErrorRequest + + // GetFlowErrorExecute executes the request + // @return FlowError + GetFlowErrorExecute(r FrontendAPIGetFlowErrorRequest) (*FlowError, *http.Response, error) + + /* + GetLoginFlow Get Login Flow + + This endpoint returns a login flow's context with, for example, error details and other information. + + Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header. + For AJAX requests you must ensure that cookies are included in the request or requests will fail. + + If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain + and you need to forward the incoming HTTP Cookie header to this endpoint: + + ```js + pseudo-code example + router.get('/login', async function (req, res) { + const flow = await client.getLoginFlow(req.header('cookie'), req.query['flow']) + + res.render('login', flow) + }) + ``` + + This request may fail due to several reasons. The `error.id` can be one of: + + `session_already_available`: The user is already signed in. + `self_service_flow_expired`: The flow is expired and you should request a new one. + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetLoginFlowRequest + */ + GetLoginFlow(ctx context.Context) FrontendAPIGetLoginFlowRequest + + // GetLoginFlowExecute executes the request + // @return LoginFlow + GetLoginFlowExecute(r FrontendAPIGetLoginFlowRequest) (*LoginFlow, *http.Response, error) + + /* + GetRecoveryFlow Get Recovery Flow + + This endpoint returns a recovery flow's context with, for example, error details and other information. + + Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header. + For AJAX requests you must ensure that cookies are included in the request or requests will fail. + + If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain + and you need to forward the incoming HTTP Cookie header to this endpoint: + + ```js + pseudo-code example + router.get('/recovery', async function (req, res) { + const flow = await client.getRecoveryFlow(req.header('Cookie'), req.query['flow']) + + res.render('recovery', flow) + }) + ``` + + More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetRecoveryFlowRequest + */ + GetRecoveryFlow(ctx context.Context) FrontendAPIGetRecoveryFlowRequest + + // GetRecoveryFlowExecute executes the request + // @return RecoveryFlow + GetRecoveryFlowExecute(r FrontendAPIGetRecoveryFlowRequest) (*RecoveryFlow, *http.Response, error) + + /* + GetRegistrationFlow Get Registration Flow + + This endpoint returns a registration flow's context with, for example, error details and other information. + + Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header. + For AJAX requests you must ensure that cookies are included in the request or requests will fail. + + If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain + and you need to forward the incoming HTTP Cookie header to this endpoint: + + ```js + pseudo-code example + router.get('/registration', async function (req, res) { + const flow = await client.getRegistrationFlow(req.header('cookie'), req.query['flow']) + + res.render('registration', flow) + }) + ``` + + This request may fail due to several reasons. The `error.id` can be one of: + + `session_already_available`: The user is already signed in. + `self_service_flow_expired`: The flow is expired and you should request a new one. + + More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetRegistrationFlowRequest + */ + GetRegistrationFlow(ctx context.Context) FrontendAPIGetRegistrationFlowRequest + + // GetRegistrationFlowExecute executes the request + // @return RegistrationFlow + GetRegistrationFlowExecute(r FrontendAPIGetRegistrationFlowRequest) (*RegistrationFlow, *http.Response, error) + + /* + GetSettingsFlow Get Settings Flow + + When accessing this endpoint through Ory Kratos' Public API you must ensure that either the Ory Kratos Session Cookie + or the Ory Kratos Session Token are set. + + Depending on your configuration this endpoint might return a 403 error if the session has a lower Authenticator + Assurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn + credentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user + to sign in with the second factor or change the configuration. + + You can access this endpoint without credentials when using Ory Kratos' Admin API. + + If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the + case of an error, the `error.id` of the JSON response body can be one of: + + `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred. + `session_inactive`: No Ory Session was found - sign in a user first. + `security_identity_mismatch`: The flow was interrupted with `session_refresh_required` but apparently some other + identity logged in instead. + + More information can be found at [Ory Kratos User Settings & Profile Management Documentation](../self-service/flows/user-settings). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetSettingsFlowRequest + */ + GetSettingsFlow(ctx context.Context) FrontendAPIGetSettingsFlowRequest + + // GetSettingsFlowExecute executes the request + // @return SettingsFlow + GetSettingsFlowExecute(r FrontendAPIGetSettingsFlowRequest) (*SettingsFlow, *http.Response, error) + + /* + GetVerificationFlow Get Verification Flow + + This endpoint returns a verification flow's context with, for example, error details and other information. + + Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header. + For AJAX requests you must ensure that cookies are included in the request or requests will fail. + + If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain + and you need to forward the incoming HTTP Cookie header to this endpoint: + + ```js + pseudo-code example + router.get('/recovery', async function (req, res) { + const flow = await client.getVerificationFlow(req.header('cookie'), req.query['flow']) + + res.render('verification', flow) + }) + ``` + + More information can be found at [Ory Kratos Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return FrontendAPIGetVerificationFlowRequest + */ + GetVerificationFlow(ctx context.Context) FrontendAPIGetVerificationFlowRequest + + // GetVerificationFlowExecute executes the request + // @return VerificationFlow + GetVerificationFlowExecute(r FrontendAPIGetVerificationFlowRequest) (*VerificationFlow, *http.Response, error) + + /* + GetWebAuthnJavaScript Get WebAuthn JavaScript + + This endpoint provides JavaScript which is needed in order to perform WebAuthn login and registration. + + If you are building a JavaScript Browser App (e.g. in ReactJS or AngularJS) you will need to load this file: + + ```html +