Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ require (
github.com/ory/client-go v0.0.0-00010101000000-000000000000
github.com/ory/dockertest/v3 v3.12.0
github.com/ory/graceful v0.1.4-0.20230301144740-e222150c51d0
github.com/ory/herodot v0.10.6
github.com/ory/herodot v0.10.7
github.com/ory/hydra-client-go/v2 v2.2.1
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e
github.com/ory/mail/v3 v3.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,8 @@ github.com/ory/go-oidc/v3 v3.0.0-20250124100243-69986dfaf891 h1:HjpfYsY85wpheyMw
github.com/ory/go-oidc/v3 v3.0.0-20250124100243-69986dfaf891/go.mod h1:Jxfv2TPRvdJuLfmkvokss8dkguhMmer2UvARU6SWy0Y=
github.com/ory/graceful v0.1.4-0.20230301144740-e222150c51d0 h1:VMUeLRfQD14fOMvhpYZIIT4vtAqxYh+f3KnSqCeJ13o=
github.com/ory/graceful v0.1.4-0.20230301144740-e222150c51d0/go.mod h1:hg2iCy+LCWOXahBZ+NQa4dk8J2govyQD79rrqrgMyY8=
github.com/ory/herodot v0.10.6 h1:BMDvzsWDS5sJISYngMJQfYBeUxIXXif6YyTFgyehnzM=
github.com/ory/herodot v0.10.6/go.mod h1:j6i246U6iX8TStYNKIVQxb2waweQvtOLi+b/9q+OULg=
github.com/ory/herodot v0.10.7 h1:CETBRP4LboLlQCSVTkyQix/a2bVh1rmNhhfxd45khCI=
github.com/ory/herodot v0.10.7/go.mod h1:j6i246U6iX8TStYNKIVQxb2waweQvtOLi+b/9q+OULg=
github.com/ory/hydra-client-go/v2 v2.2.1 h1:m1821pIX6ybG/3oSAn2wtrbBKNwe9q5A8fLljYuLpBk=
github.com/ory/hydra-client-go/v2 v2.2.1/go.mod h1:K83R+iK40+5uF2uQ34yRUrf9izRvFsza9pG2Se5qMmk=
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e h1:4tUrC7x4YWRVMFp+c64KACNSGchW1zXo4l6Pa9/1hA8=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
"config": {
"credentials": [
{
"added_at": "2022-12-16T14:11:55Z",
"public_key": "pQECAyYgASFYIMJLQhJxQRzhnKPTcPCUODOmxYDYo2obrm9bhp5lvSZ3IlggXjhZvJaPUqF9PXqZqTdWYPR7R+b2n/Wi+IxKKXsS4rU=",
"attestation_type": "none",
"display_name": "test",
"authenticator": {
"aaguid": "rc4AAjW8xgpkiwsl8fBVAw==",
"sign_count": 0,
"clone_warning": false
},
"display_name": "test",
"added_at": "2022-12-16T14:11:55Z",
"is_passwordless": true
"is_passwordless": true,
"attestation_type": "none"
}
],
"user_handle": "Ef5JiMpMRwuzauWs/9J0gQ=="
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
"config": {
"credentials": [
{
"added_at": "2022-12-16T14:11:55Z",
"public_key": "pQECAyYgASFYIMJLQhJxQRzhnKPTcPCUODOmxYDYo2obrm9bhp5lvSZ3IlggXjhZvJaPUqF9PXqZqTdWYPR7R+b2n/Wi+IxKKXsS4rU=",
"attestation_type": "none",
"display_name": "test",
"authenticator": {
"aaguid": "rc4AAjW8xgpkiwsl8fBVAw==",
"sign_count": 0,
"clone_warning": false
},
"display_name": "test",
"added_at": "2022-12-16T14:11:55Z",
"is_passwordless": true
"is_passwordless": true,
"attestation_type": "none"
}
],
"user_handle": "Ef5JiMpMRwuzauWs/9J0gQ=="
Expand Down
21 changes: 21 additions & 0 deletions identity/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ package identity
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"time"

"github.com/gofrs/uuid"
Expand Down Expand Up @@ -191,6 +195,23 @@ func (c Credentials) GetID() uuid.UUID {
return c.ID
}

// Signature returns a unique string signature for the credential.
func (c Credentials) Signature() string {
sortedIdentifiers := slices.Clone(c.Identifiers)
slices.Sort(sortedIdentifiers)
identifiersStr := strings.Join(sortedIdentifiers, ",")

// Normalize JSON config to remove whitespace and key ordering differences
var normalizedConfig any
if len(c.Config) > 0 {
if err := json.Unmarshal(c.Config, &normalizedConfig); err != nil {
// there is not much we can do when unmarshal fails except use the raw value
normalizedConfig = c.Config
}
}
return fmt.Sprintf("%v|%v|%d|%+v|%v|%v", c.Type, identifiersStr, c.Version, normalizedConfig, c.IdentityID, c.NID)
}

type (
// swagger:ignore
CredentialIdentifier struct {
Expand Down
206 changes: 204 additions & 2 deletions identity/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ package identity
import (
"testing"

"github.com/stretchr/testify/require"

"github.com/gofrs/uuid"
"github.com/mohae/deepcopy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ory/x/sqlxx"
)
Expand Down Expand Up @@ -55,3 +55,205 @@ func TestParseCredentialsType(t *testing.T) {
require.False(t, ok)
})
}

func TestCredentials_Hash(t *testing.T) {
baseID := uuid.Must(uuid.NewV4())
baseNID := uuid.Must(uuid.NewV4())

for _, tc := range []struct {
name string
cred1 Credentials
cred2 Credentials
expectEqual bool
description string
}{
{
name: "same json with different whitespace",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar","baz":"qux"}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{
"foo": "bar",
"baz": "qux"
}`),
Version: 1,
},
expectEqual: true,
description: "hashes should be equal for same JSON with different whitespace",
},
{
name: "same json with different key order",
cred1: Credentials{
Type: CredentialsTypeCodeAuth,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"addresses":[{"address":"test@example.com","channel":"email"}]}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeCodeAuth,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"test@example.com"}]}`),
Version: 1,
},
expectEqual: true,
description: "hashes should be equal for same JSON with different key order",
},
{
name: "nested json with different key order",
cred1: Credentials{
Type: CredentialsTypeWebAuthn,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"credentials":[{"id":"abc","public_key":"xyz","type":"webauthn"}]}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeWebAuthn,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"credentials":[{"type":"webauthn","public_key":"xyz","id":"abc"}]}`),
Version: 1,
},
expectEqual: true,
description: "hashes should be equal for nested JSON with different key order",
},
{
name: "same identifiers in different order",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"a@example.com", "b@example.com", "c@example.com"},
Config: sqlxx.JSONRawMessage(`{}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"c@example.com", "a@example.com", "b@example.com"},
Config: sqlxx.JSONRawMessage(`{}`),
Version: 1,
},
expectEqual: true,
description: "hashes should be equal for same identifiers in different order",
},
{
name: "different json config",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"different"}`),
Version: 1,
},
expectEqual: false,
description: "hashes should be different for different JSON content",
},
{
name: "different types",
cred1: Credentials{
Type: CredentialsTypePassword,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
expectEqual: false,
description: "hashes should be different for different types",
},
{
name: "different identifiers",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test1@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test2@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
expectEqual: false,
description: "hashes should be different for different identifiers",
},
{
name: "different versions",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 2,
},
expectEqual: false,
description: "hashes should be different for different versions",
},
{
name: "different identity IDs",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
IdentityID: baseID,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
IdentityID: uuid.Must(uuid.NewV4()),
},
expectEqual: false,
description: "hashes should be different for different identity IDs",
},
{
name: "different NIDs",
cred1: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
NID: baseNID,
},
cred2: Credentials{
Type: CredentialsTypeOIDC,
Identifiers: []string{"test@example.com"},
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
Version: 1,
NID: uuid.Must(uuid.NewV4()),
},
expectEqual: false,
description: "hashes should be different for different NIDs",
},
} {
t.Run("case="+tc.name, func(t *testing.T) {
hash1 := tc.cred1.Signature()
hash2 := tc.cred2.Signature()

if tc.expectEqual {
assert.Equal(t, hash1, hash2, tc.description)
} else {
assert.NotEqual(t, hash1, hash2, tc.description)
}
})
}
}
1 change: 1 addition & 0 deletions identity/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func (i *Identity) SetCredentials(t CredentialsType, c Credentials) {
}

c.Type = t
c.IdentityID = i.ID
i.Credentials[t] = c
}

Expand Down
4 changes: 2 additions & 2 deletions identity/identity_recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ func (v RecoveryAddressType) HTMLFormInputType() string {
func (a RecoveryAddress) TableName() string { return "identity_recovery_addresses" }
func (a RecoveryAddress) GetID() uuid.UUID { return a.ID }

// Hash returns a unique string representation for the recovery address.
func (a RecoveryAddress) Hash() string {
// Signature returns a unique string representation for the recovery address.
func (a RecoveryAddress) Signature() string {
return fmt.Sprintf("%v|%v|%v|%v", a.Value, a.Via, a.IdentityID, a.NID)
}

Expand Down
2 changes: 1 addition & 1 deletion identity/identity_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestRecoveryAddress_Hash(t *testing.T) {
t.Run("case="+tc.name, func(t *testing.T) {
assert.Equal(t,
reflectiveHash(tc.a),
tc.a.Hash(),
tc.a.Signature(),
)
})
}
Expand Down
4 changes: 2 additions & 2 deletions identity/identity_verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func (a VerifiableAddress) GetID() uuid.UUID {
return a.ID
}

// Hash returns a unique string representation for the recovery address.
func (a VerifiableAddress) Hash() string {
// Signature returns a unique string representation for the recovery address.
func (a VerifiableAddress) Signature() string {
return fmt.Sprintf("%v|%v|%v|%v|%v|%v|%v", a.Value, a.Verified, a.Via, a.Status, a.VerifiedAt, a.IdentityID, a.NID)
}
2 changes: 1 addition & 1 deletion identity/identity_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func TestVerifiableAddress_Hash(t *testing.T) {
t.Run("case="+tc.name, func(t *testing.T) {
assert.Equal(t,
reflectiveHash(tc.a),
tc.a.Hash(),
tc.a.Signature(),
)
})
}
Expand Down
Loading
Loading