Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
899 lines
25 KiB
Go
899 lines
25 KiB
Go
// Copyright © 2025 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package identity
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/x/snapshotx"
|
|
)
|
|
|
|
func TestImportCredentialsOIDCSAML(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Setup handler with minimal mock requirements
|
|
h := &Handler{}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
setupIdentity func() *Identity
|
|
credentials interface{}
|
|
credType CredentialsType
|
|
}{
|
|
{
|
|
name: "OIDC new credential without organization",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsOIDC{
|
|
Config: AdminIdentityImportCredentialsOIDCConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsOIDCProvider{
|
|
{
|
|
Provider: "github",
|
|
Subject: "12345",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeOIDC,
|
|
},
|
|
{
|
|
name: "OIDC new credential with organization",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsOIDC{
|
|
Config: AdminIdentityImportCredentialsOIDCConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsOIDCProvider{
|
|
{
|
|
Provider: "github",
|
|
Subject: "12345",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeOIDC,
|
|
},
|
|
{
|
|
name: "OIDC update credential without organization",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeOIDC,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("google", "67890")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "google",
|
|
Subject: "67890",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsOIDC{
|
|
Config: AdminIdentityImportCredentialsOIDCConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsOIDCProvider{
|
|
{
|
|
Provider: "github",
|
|
Subject: "12345",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeOIDC,
|
|
},
|
|
{
|
|
name: "OIDC update credential with organization",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeOIDC,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("google", "67890")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "google",
|
|
Subject: "67890",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsOIDC{
|
|
Config: AdminIdentityImportCredentialsOIDCConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsOIDCProvider{
|
|
{
|
|
Provider: "github",
|
|
Subject: "12345",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeOIDC,
|
|
},
|
|
{
|
|
name: "OIDC update with multiple providers",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeOIDC,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("google", "67890")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "google",
|
|
Subject: "67890",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsOIDC{
|
|
Config: AdminIdentityImportCredentialsOIDCConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsOIDCProvider{
|
|
{
|
|
Provider: "github",
|
|
Subject: "12345",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
{
|
|
Provider: "gitlab",
|
|
Subject: "abcdef",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeOIDC,
|
|
},
|
|
{
|
|
name: "SAML new credential without organization",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsSAML{
|
|
Config: AdminIdentityImportCredentialsSAMLConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsSAMLProvider{
|
|
{
|
|
Provider: "okta",
|
|
Subject: "user123",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeSAML,
|
|
},
|
|
{
|
|
name: "SAML new credential with organization",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsSAML{
|
|
Config: AdminIdentityImportCredentialsSAMLConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsSAMLProvider{
|
|
{
|
|
Provider: "okta",
|
|
Subject: "user123",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeSAML,
|
|
},
|
|
{
|
|
name: "SAML update credential without organization",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeSAML,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("onelogin", "user456")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "onelogin",
|
|
Subject: "user456",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsSAML{
|
|
Config: AdminIdentityImportCredentialsSAMLConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsSAMLProvider{
|
|
{
|
|
Provider: "okta",
|
|
Subject: "user123",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeSAML,
|
|
},
|
|
{
|
|
name: "SAML update credential with organization",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeSAML,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("onelogin", "user456")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "onelogin",
|
|
Subject: "user456",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsSAML{
|
|
Config: AdminIdentityImportCredentialsSAMLConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsSAMLProvider{
|
|
{
|
|
Provider: "okta",
|
|
Subject: "user123",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeSAML,
|
|
},
|
|
{
|
|
name: "SAML update with multiple providers",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeSAML,
|
|
Credentials{
|
|
Identifiers: []string{OIDCUniqueID("onelogin", "user456")},
|
|
},
|
|
CredentialsOIDC{
|
|
Providers: []CredentialsOIDCProvider{
|
|
{
|
|
Provider: "onelogin",
|
|
Subject: "user456",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsSAML{
|
|
Config: AdminIdentityImportCredentialsSAMLConfig{
|
|
Providers: []AdminCreateIdentityImportCredentialsSAMLProvider{
|
|
{
|
|
Provider: "okta",
|
|
Subject: "user123",
|
|
Organization: uuid.NullUUID{UUID: uuid.FromStringOrNil("e7e3cbae-04cc-45f3-ae52-ea749a2ffaff"), Valid: true},
|
|
},
|
|
{
|
|
Provider: "auth0",
|
|
Subject: "user789",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
credType: CredentialsTypeSAML,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Set up a fresh identity for each test
|
|
i := tc.setupIdentity()
|
|
var err error
|
|
|
|
// Perform the import based on a credential type
|
|
switch tc.credType {
|
|
case CredentialsTypeOIDC:
|
|
err = h.importOIDCCredentials(ctx, i, tc.credentials.(*AdminIdentityImportCredentialsOIDC))
|
|
case CredentialsTypeSAML:
|
|
err = h.importSAMLCredentials(ctx, i, tc.credentials.(*AdminIdentityImportCredentialsSAML))
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Verify credential was set correctly
|
|
creds, ok := i.GetCredentials(tc.credType)
|
|
require.True(t, ok, "credentials should be set")
|
|
|
|
// Verify the credentials contain proper identifiers and config
|
|
assert.NotEmpty(t, creds.Identifiers)
|
|
assert.NotEmpty(t, creds.Config)
|
|
|
|
// Take a snapshot of the credentials
|
|
snapshotx.SnapshotT(t, creds)
|
|
|
|
// Additional checks based on credential type
|
|
switch tc.credType {
|
|
case CredentialsTypeOIDC:
|
|
oidcCreds := tc.credentials.(*AdminIdentityImportCredentialsOIDC)
|
|
for _, p := range oidcCreds.Config.Providers {
|
|
id := OIDCUniqueID(p.Provider, p.Subject)
|
|
assert.Contains(t, creds.Identifiers, id)
|
|
}
|
|
case CredentialsTypeSAML:
|
|
samlCreds := tc.credentials.(*AdminIdentityImportCredentialsSAML)
|
|
for _, p := range samlCreds.Config.Providers {
|
|
id := OIDCUniqueID(p.Provider, p.Subject)
|
|
assert.Contains(t, creds.Identifiers, id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImportLookupSecretCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Setup handler with minimal mock requirements
|
|
h := &Handler{}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
setupIdentity func() *Identity
|
|
credentials *AdminIdentityImportCredentialsLookupSecret
|
|
expectedCodes int
|
|
}{
|
|
{
|
|
name: "new lookup secret credential",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsLookupSecret{
|
|
Config: AdminIdentityImportCredentialsLookupSecretConfig{
|
|
Codes: []RecoveryCode{
|
|
{Code: "code1"},
|
|
{Code: "code2"},
|
|
},
|
|
},
|
|
},
|
|
expectedCodes: 2,
|
|
},
|
|
{
|
|
name: "update existing lookup secret credential",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
_ = i.SetCredentialsWithConfig(
|
|
CredentialsTypeLookup,
|
|
Credentials{},
|
|
CredentialsLookupConfig{
|
|
RecoveryCodes: []RecoveryCode{
|
|
{Code: "existing-code"},
|
|
},
|
|
},
|
|
)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsLookupSecret{
|
|
Config: AdminIdentityImportCredentialsLookupSecretConfig{
|
|
Codes: []RecoveryCode{
|
|
{Code: "new-code1"},
|
|
{Code: "new-code2"},
|
|
},
|
|
},
|
|
},
|
|
expectedCodes: 3, // 1 existing + 2 new
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Set up a fresh identity for each test
|
|
i := tc.setupIdentity()
|
|
|
|
// Perform the import
|
|
err := h.importLookupSecretCredentials(ctx, i, tc.credentials)
|
|
require.NoError(t, err)
|
|
|
|
// Verify credential was set correctly
|
|
creds, ok := i.GetCredentials(CredentialsTypeLookup)
|
|
require.True(t, ok, "credentials should be set")
|
|
|
|
// Parse the config to check the recovery codes
|
|
var config CredentialsLookupConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
// Verify the expected number of codes
|
|
assert.Len(t, config.RecoveryCodes, tc.expectedCodes)
|
|
|
|
// Take a snapshot of the credentials
|
|
snapshotx.SnapshotT(t, creds)
|
|
|
|
// Verify specific codes based on a test case
|
|
if tc.name == "new lookup secret credential" {
|
|
assert.Contains(t, getCodeValues(config.RecoveryCodes), "code1")
|
|
assert.Contains(t, getCodeValues(config.RecoveryCodes), "code2")
|
|
} else {
|
|
assert.Contains(t, getCodeValues(config.RecoveryCodes), "existing-code")
|
|
assert.Contains(t, getCodeValues(config.RecoveryCodes), "new-code1")
|
|
assert.Contains(t, getCodeValues(config.RecoveryCodes), "new-code2")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImportPasskeyCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Setup handler
|
|
h := &Handler{}
|
|
|
|
// Create test credentials
|
|
cred1 := CredentialWebAuthn{ID: []byte("cred1"), PublicKey: []byte("pk1")}
|
|
cred2 := CredentialWebAuthn{ID: []byte("cred2"), PublicKey: []byte("pk2")}
|
|
cred1Updated := CredentialWebAuthn{ID: []byte("cred1"), PublicKey: []byte("pk1-updated")}
|
|
cred3 := CredentialWebAuthn{ID: []byte("cred3"), PublicKey: []byte("pk3")}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
setupIdentity func() *Identity
|
|
credentials CredentialsWebAuthn
|
|
userHandle []byte
|
|
verify func(t *testing.T, i *Identity)
|
|
}{
|
|
{
|
|
name: "new passkey credentials",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: CredentialsWebAuthn{cred1, cred2},
|
|
userHandle: []byte("user1"),
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypePasskey)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
assert.Len(t, config.Credentials, 2)
|
|
assert.Equal(t, []byte("user1"), config.UserHandle)
|
|
assert.Contains(t, creds.Identifiers, "user1")
|
|
},
|
|
},
|
|
{
|
|
name: "update existing credentials - add new, update existing",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
err := i.SetCredentialsWithConfig(
|
|
CredentialsTypePasskey,
|
|
Credentials{
|
|
Identifiers: []string{"existingUser"},
|
|
},
|
|
CredentialsWebAuthnConfig{
|
|
Credentials: CredentialsWebAuthn{cred1, cred2},
|
|
UserHandle: []byte("existingUser"),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return i
|
|
},
|
|
credentials: CredentialsWebAuthn{cred1Updated, cred3},
|
|
userHandle: []byte{}, // Empty to test reusing existing
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypePasskey)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
// Should have updated cred1, kept cred2, and added cred3
|
|
assert.Len(t, config.Credentials, 3)
|
|
assert.Equal(t, []byte("existingUser"), config.UserHandle)
|
|
|
|
// Find the updated credential
|
|
var found bool
|
|
for _, c := range config.Credentials {
|
|
if bytes.Equal(c.ID, []byte("cred1")) {
|
|
assert.Equal(t, []byte("pk1-updated"), c.PublicKey)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Updated credential should be present")
|
|
|
|
// Verify cred3 was added
|
|
var foundNew bool
|
|
for _, c := range config.Credentials {
|
|
if bytes.Equal(c.ID, []byte("cred3")) {
|
|
foundNew = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundNew, "New credential should be added")
|
|
},
|
|
},
|
|
{
|
|
name: "new user handle added to identifiers",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
err := i.SetCredentialsWithConfig(
|
|
CredentialsTypePasskey,
|
|
Credentials{
|
|
Identifiers: []string{"existingUser"},
|
|
},
|
|
CredentialsWebAuthnConfig{
|
|
Credentials: CredentialsWebAuthn{cred1},
|
|
UserHandle: []byte("existingUser"),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return i
|
|
},
|
|
credentials: CredentialsWebAuthn{cred2},
|
|
userHandle: []byte("newUser"),
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypePasskey)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
assert.Equal(t, []byte("newUser"), config.UserHandle)
|
|
assert.Contains(t, creds.Identifiers, "existingUser")
|
|
assert.Contains(t, creds.Identifiers, "newUser")
|
|
assert.Len(t, creds.Identifiers, 2)
|
|
assert.Len(t, config.Credentials, 2)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
i := tc.setupIdentity()
|
|
err := h.importPasskeyCredentials(ctx, i, tc.credentials, tc.userHandle)
|
|
require.NoError(t, err)
|
|
tc.verify(t, i)
|
|
snapshotx.SnapshotT(t, i.Credentials)
|
|
})
|
|
}
|
|
|
|
// Negative cases — input that would store a non-functional credential
|
|
// must be rejected up front. See
|
|
// https://github.com/ory/kratos/issues/4561.
|
|
//
|
|
// herodot wraps the user-facing detail in Reason; err.Error() returns
|
|
// the canonical status message ("The request was malformed or
|
|
// contained invalid parameters"), so unwrap to a *herodot.DefaultError
|
|
// and assert against Reason() instead.
|
|
requireBadRequestReason := func(t *testing.T, err error, mustContain string) {
|
|
t.Helper()
|
|
require.Error(t, err)
|
|
var herr *herodot.DefaultError
|
|
require.True(t, errors.As(err, &herr), "expected a *herodot.DefaultError, got %T: %v", err, err)
|
|
assert.Equal(t, herodot.ErrBadRequest().StatusCode(), herr.StatusCode())
|
|
assert.Contains(t, herr.Reason(), mustContain)
|
|
}
|
|
|
|
t.Run("case=rejects import without user_handle on a new credential", func(t *testing.T) {
|
|
i := &Identity{}
|
|
err := h.importPasskeyCredentials(ctx, i, CredentialsWebAuthn{cred1}, nil)
|
|
requireBadRequestReason(t, err, "user_handle")
|
|
_, ok := i.GetCredentials(CredentialsTypePasskey)
|
|
assert.False(t, ok, "no passkey credential should be persisted on the identity")
|
|
})
|
|
|
|
t.Run("case=rejects import without user_handle when previous credential also lacks one", func(t *testing.T) {
|
|
i := &Identity{}
|
|
require.NoError(t, i.SetCredentialsWithConfig(
|
|
CredentialsTypePasskey,
|
|
Credentials{},
|
|
CredentialsWebAuthnConfig{Credentials: CredentialsWebAuthn{cred1}},
|
|
))
|
|
err := h.importPasskeyCredentials(ctx, i, CredentialsWebAuthn{cred2}, nil)
|
|
requireBadRequestReason(t, err, "user_handle")
|
|
})
|
|
|
|
t.Run("case=rejects import without any credentials", func(t *testing.T) {
|
|
i := &Identity{}
|
|
err := h.importPasskeyCredentials(ctx, i, CredentialsWebAuthn{}, []byte("user1"))
|
|
requireBadRequestReason(t, err, "credentials")
|
|
})
|
|
}
|
|
|
|
func TestImportWebAuthnCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Setup handler
|
|
h := &Handler{}
|
|
|
|
// Create test credentials
|
|
cred1 := CredentialWebAuthn{ID: []byte("cred1"), PublicKey: []byte("pk1")}
|
|
cred2 := CredentialWebAuthn{ID: []byte("cred2"), PublicKey: []byte("pk2")}
|
|
cred1Updated := CredentialWebAuthn{ID: []byte("cred1"), PublicKey: []byte("pk1-updated")}
|
|
cred3 := CredentialWebAuthn{ID: []byte("cred3"), PublicKey: []byte("pk3")}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
setupIdentity func() *Identity
|
|
credentials CredentialsWebAuthn
|
|
userHandle []byte
|
|
verify func(t *testing.T, i *Identity)
|
|
}{
|
|
{
|
|
name: "new webauthn credentials",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: CredentialsWebAuthn{cred1, cred2},
|
|
userHandle: []byte("user1"),
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypeWebAuthn)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
assert.Len(t, config.Credentials, 2)
|
|
assert.Equal(t, []byte("user1"), config.UserHandle)
|
|
},
|
|
},
|
|
{
|
|
name: "update existing credentials - add new, update existing",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
err := i.SetCredentialsWithConfig(
|
|
CredentialsTypeWebAuthn,
|
|
Credentials{},
|
|
CredentialsWebAuthnConfig{
|
|
Credentials: CredentialsWebAuthn{cred1, cred2},
|
|
UserHandle: []byte("existingUser"),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return i
|
|
},
|
|
credentials: CredentialsWebAuthn{cred1Updated, cred3},
|
|
userHandle: []byte{}, // Empty to test reusing existing
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypeWebAuthn)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
// Should have updated cred1, kept cred2, and added cred3
|
|
assert.Len(t, config.Credentials, 3)
|
|
assert.Equal(t, []byte("existingUser"), config.UserHandle)
|
|
|
|
// Find the updated credential
|
|
var found bool
|
|
for _, c := range config.Credentials {
|
|
if bytes.Equal(c.ID, []byte("cred1")) {
|
|
assert.Equal(t, []byte("pk1-updated"), c.PublicKey)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Updated credential should be present")
|
|
|
|
// Verify cred3 was added
|
|
var foundNew bool
|
|
for _, c := range config.Credentials {
|
|
if bytes.Equal(c.ID, []byte("cred3")) {
|
|
foundNew = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundNew, "New credential should be added")
|
|
},
|
|
},
|
|
{
|
|
name: "override existing user handle",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
err := i.SetCredentialsWithConfig(
|
|
CredentialsTypeWebAuthn,
|
|
Credentials{},
|
|
CredentialsWebAuthnConfig{
|
|
Credentials: CredentialsWebAuthn{cred1},
|
|
UserHandle: []byte("existingUser"),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return i
|
|
},
|
|
credentials: CredentialsWebAuthn{cred2},
|
|
userHandle: []byte("newUser"),
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypeWebAuthn)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
assert.Equal(t, []byte("newUser"), config.UserHandle)
|
|
assert.Len(t, config.Credentials, 2)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
i := tc.setupIdentity()
|
|
err := h.importWebAuthnCredentials(ctx, i, tc.credentials, tc.userHandle)
|
|
require.NoError(t, err)
|
|
tc.verify(t, i)
|
|
snapshotx.SnapshotT(t, i.Credentials)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImportTOTPCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Setup handler
|
|
h := &Handler{}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
setupIdentity func() *Identity
|
|
credentials *AdminIdentityImportCredentialsTOTP
|
|
verify func(t *testing.T, i *Identity)
|
|
}{
|
|
{
|
|
name: "new totp credential",
|
|
setupIdentity: func() *Identity {
|
|
return &Identity{}
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsTOTP{
|
|
Config: AdminIdentityImportCredentialsTOTPConfig{
|
|
TOTPURL: "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
|
},
|
|
},
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypeTOTP)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsTOTPConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
assert.Equal(t, "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", config.TOTPURL)
|
|
// The importer leaves Identifiers empty because the identity ID
|
|
// is not yet known. The persister defaults it to the persisted
|
|
// identity ID at credential-create time. See
|
|
// https://github.com/ory/kratos/issues/4561.
|
|
assert.Empty(t, creds.Identifiers)
|
|
},
|
|
},
|
|
{
|
|
name: "update existing totp credential",
|
|
setupIdentity: func() *Identity {
|
|
i := &Identity{}
|
|
err := i.SetCredentialsWithConfig(
|
|
CredentialsTypeTOTP,
|
|
Credentials{},
|
|
CredentialsTOTPConfig{
|
|
TOTPURL: "otpauth://totp/Example:alice@example.com?secret=OLDSECRET&issuer=Example",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return i
|
|
},
|
|
credentials: &AdminIdentityImportCredentialsTOTP{
|
|
Config: AdminIdentityImportCredentialsTOTPConfig{
|
|
TOTPURL: "otpauth://totp/Example:alice@example.com?secret=NEWSECRET&issuer=Example",
|
|
},
|
|
},
|
|
verify: func(t *testing.T, i *Identity) {
|
|
creds, ok := i.GetCredentials(CredentialsTypeTOTP)
|
|
require.True(t, ok)
|
|
|
|
var config CredentialsTOTPConfig
|
|
require.NoError(t, json.Unmarshal(creds.Config, &config))
|
|
|
|
// Should have the new TOTP URL
|
|
assert.Equal(t, "otpauth://totp/Example:alice@example.com?secret=NEWSECRET&issuer=Example", config.TOTPURL)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
i := tc.setupIdentity()
|
|
err := h.importTOTPCredentials(ctx, i, tc.credentials)
|
|
require.NoError(t, err)
|
|
tc.verify(t, i)
|
|
snapshotx.SnapshotT(t, i.Credentials)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to extract code values for easier assertions
|
|
func getCodeValues(codes []RecoveryCode) []string {
|
|
var values []string
|
|
for _, code := range codes {
|
|
values = append(values, code.Code)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// TestBatchImport_RegionWireThrough verifies that identityFromCreateIdentityBody
|
|
// correctly propagates the Region field from CreateIdentityBody to the resulting
|
|
// Identity for each entry in a batch import. This ensures the multi-region
|
|
// persister receives identities with the correct region so it can route INSERTs
|
|
// to the right CockroachDB region.
|
|
func TestBatchImport_RegionWireThrough(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// A minimal handler is sufficient: importCredentials is a no-op when
|
|
// Credentials is nil, so no registry wiring is needed.
|
|
h := &Handler{}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
body *CreateIdentityBody
|
|
expectedRegion string
|
|
}{
|
|
{
|
|
name: "region eu-central is propagated",
|
|
body: &CreateIdentityBody{
|
|
SchemaID: "default",
|
|
Traits: json.RawMessage(`{"bar":"baz"}`),
|
|
Region: "eu-central",
|
|
},
|
|
expectedRegion: "eu-central",
|
|
},
|
|
{
|
|
name: "region us-west is propagated",
|
|
body: &CreateIdentityBody{
|
|
SchemaID: "default",
|
|
Traits: json.RawMessage(`{"bar":"baz"}`),
|
|
Region: "us-west",
|
|
},
|
|
expectedRegion: "us-west",
|
|
},
|
|
{
|
|
name: "empty region is preserved as empty",
|
|
body: &CreateIdentityBody{
|
|
SchemaID: "default",
|
|
Traits: json.RawMessage(`{"bar":"baz"}`),
|
|
},
|
|
expectedRegion: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ident, err := h.identityFromCreateIdentityBody(ctx, tc.body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedRegion, string(ident.Region))
|
|
})
|
|
}
|
|
}
|