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
337 lines
15 KiB
Go
337 lines
15 KiB
Go
// Copyright © 2023 Ory Corp
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
package identity_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/ory/jsonschema/v3"
|
||
_ "github.com/ory/jsonschema/v3/fileloader"
|
||
"github.com/ory/kratos/identity"
|
||
"github.com/ory/kratos/schema"
|
||
"github.com/ory/kratos/x"
|
||
"github.com/ory/x/snapshotx"
|
||
"github.com/ory/x/sqlxx"
|
||
)
|
||
|
||
func TestSchemaExtensionCredentials(t *testing.T) {
|
||
for k, tc := range []struct {
|
||
expectErr error
|
||
schema string
|
||
doc string
|
||
expectedIdentifiers []string
|
||
existing *identity.Credentials
|
||
ct identity.CredentialsType
|
||
}{
|
||
{
|
||
doc: `{"email":"foo@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"emails":["foo😀@ory.sh","foo😀@ory.sh","bar@ory.sh"], "username": "Foo🥳🖐baR"}`,
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"foo😀@ory.sh", "bar@ory.sh", "foo🥳🖐bar"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"emails":["föo@ory.sh","föo@ory.sh","bar@ory.sh"], "username": "foobar"}`,
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"föo@ory.sh", "bar@ory.sh"},
|
||
ct: identity.CredentialsTypeWebAuthn,
|
||
},
|
||
{
|
||
doc: `{"emails":["FÖÖ@ory.sh","bar@ory.sh"], "username": "foobär"}`,
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"föö@ory.sh", "bar@ory.sh", "foobär"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh"},
|
||
},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"email":"foo@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/webauthn.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
ct: identity.CredentialsTypeWebAuthn,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/webauthn.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh"},
|
||
},
|
||
ct: identity.CredentialsTypeWebAuthn,
|
||
},
|
||
{
|
||
doc: `{"email":"foo@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/code.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/code.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh"},
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/code.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"not-foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"+4917667111638", "foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"not-foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"+4917667111638", "foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh", "foo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"not-foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh","email2":"FOO@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"+4917667111638", "foo@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh", "fOo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"not-foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"FOO@ory.sh","email2":"FOO@ory.sh","email3":"bar@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"+4917667111638", "foo@ory.sh", "bar@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"not-foo@ory.sh", "fOo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"not-foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
{
|
||
doc: `{"email":"bar@ory.sh"}`,
|
||
schema: "file://./stub/extension/credentials/email.schema.json",
|
||
expectedIdentifiers: []string{"bar@ory.sh"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"foo@ory.sh"},
|
||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"foo@ory.sh"}]}`),
|
||
},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
|
||
{
|
||
doc: `{"email":"foo@ory.sh","email2":"foo@ory.sh","email3":"bar@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh", "bar@ory.sh", "+4917667111638"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"email":"foo@ory.sh","phone":"+49 176 671 11 638"}`,
|
||
schema: "file://./stub/extension/credentials/code-phone-email.schema.json",
|
||
expectedIdentifiers: []string{"foo@ory.sh", "+4917667111638"},
|
||
existing: &identity.Credentials{
|
||
Identifiers: []string{"foo@ory.sh", "+49 176 671 11 638"},
|
||
},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"emails":["bar@o😶ry.sh"], "username": "Foo🥳🖐baR"}`,
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
ct: identity.CredentialsTypePassword,
|
||
expectErr: errors.New("I[#/emails/0] S[#/properties/emails/items/format] \"bar@o😶ry.sh\" is not valid \"email\""),
|
||
},
|
||
{
|
||
doc: `{"email":"foo@öry.sh"}`,
|
||
schema: "file://./stub/extension/credentials/webauthn.schema.json",
|
||
expectErr: errors.New(`I[#/email] S[#/properties/email/format] "foo@öry.sh" is not valid "email"`),
|
||
ct: identity.CredentialsTypeWebAuthn,
|
||
},
|
||
|
||
// Invisible and bidi-control characters must be stripped from both
|
||
// emails and usernames. Each injected rune below has been used in
|
||
// real homograph / duplicate-account bypasses: the poisoned input
|
||
// must canonicalize to the same baseline an attacker is trying to
|
||
// impersonate, so the UNIQUE constraint on
|
||
// identity_credential_identifiers rejects the duplicate.
|
||
//
|
||
// The runes are built with fmt.Sprintf("%c", …) on purpose: literal
|
||
// invisible characters in test source are unreviewable, and a literal
|
||
// U+FEFF (BOM) is rejected by the Go compiler outright.
|
||
|
||
// Zero-width space (U+200B) inside an email local part.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["ad%cmin@ory.sh"],"username":"zwsp-email"}`, 0x200B),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "zwsp-email"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Several different invisibles (ZWSP, ZWJ, soft hyphen) in one email.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["a%cd%cmi%cn@ory.sh"],"username":"multi-email"}`, 0x200B, 0x200D, 0x00AD),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "multi-email"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// BOM (U+FEFF) prefix and word joiner (U+2060) suffix in an email.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["%cadmin%c@ory.sh"],"username":"bom-email"}`, 0xFEFF, 0x2060),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "bom-email"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Right-to-left override (U+202E, Trojan-source style) in an email.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["ad%cmin@ory.sh"],"username":"rlo-email"}`, 0x202E),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "rlo-email"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Zero-width space (U+200B) inside a username.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["zwsp-user@ory.sh"],"username":"ad%cmin"}`, 0x200B),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"zwsp-user@ory.sh", "admin"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Default-ignorable runes outside category Cf in a username: ZWNJ,
|
||
// combining grapheme joiner (U+034F), Hangul filler (U+3164), and
|
||
// variation selector-16 (U+FE0F) all collapse to the baseline.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["multi-user@ory.sh"],"username":"a%cd%cm%ci%cn"}`, 0x200C, 0x034F, 0x3164, 0xFE0F),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"multi-user@ory.sh", "admin"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Bidi override (U+202E) plus word joiner (U+2060) and soft hyphen
|
||
// (U+00AD) wrapping a username.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["rlo-user@ory.sh"],"username":"%cad%cmin%c"}`, 0x202E, 0x2060, 0x00AD),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"rlo-user@ory.sh", "admin"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Two differently-poisoned variants of the same email must dedupe to
|
||
// a single identifier — this is the duplicate-account bypass itself.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["ad%cmin@ory.sh","ad%cmin@ory.sh"],"username":"dedup"}`, 0x200B, 0x00AD),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "dedup"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Boundary: NFKC does not fold script confusables. A Cyrillic "а"
|
||
// (U+0430) is a distinct letter from Latin "a", so this username
|
||
// stays distinct. Confusable-script detection is intentionally out
|
||
// of scope for identifier normalization.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["homoglyph@ory.sh"],"username":"%cdmin"}`, 0x0430),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"homoglyph@ory.sh", fmt.Sprintf("%cdmin", 0x0430)},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
// Two emails that differ only by a confusable character — Latin "a"
|
||
// versus Cyrillic "а" (U+0430) — are not folded by NFKC, so both are
|
||
// taken as distinct identifiers rather than collapsing into one.
|
||
{
|
||
doc: fmt.Sprintf(`{"emails":["admin@ory.sh","%cdmin@ory.sh"],"username":"confusable-emails"}`, 0x0430),
|
||
schema: "file://./stub/extension/credentials/multi.schema.json",
|
||
expectedIdentifiers: []string{"admin@ory.sh", "аdmin@ory.sh", "confusable-emails"},
|
||
ct: identity.CredentialsTypePassword,
|
||
},
|
||
{
|
||
doc: `{"email":"foo.@docomo.ne.jp"}`,
|
||
schema: "file://./stub/extension/credentials/code-no-validate.schema.json",
|
||
expectedIdentifiers: []string{"foo.@docomo.ne.jp"},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
// Uppercase input under no-validate must normalize to match the
|
||
// login-code lookup, which compares x.GracefulNormalization(identifier).
|
||
{
|
||
doc: `{"email":"FOO.@docomo.ne.jp"}`,
|
||
schema: "file://./stub/extension/credentials/code-no-validate.schema.json",
|
||
expectedIdentifiers: []string{"foo.@docomo.ne.jp"},
|
||
ct: identity.CredentialsTypeCodeAuth,
|
||
},
|
||
} {
|
||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||
c := jsonschema.NewCompiler()
|
||
runner, err := schema.NewExtensionRunner(t.Context())
|
||
require.NoError(t, err)
|
||
|
||
i := new(identity.Identity)
|
||
e := identity.NewSchemaExtensionCredentials(i)
|
||
if tc.existing != nil {
|
||
i.SetCredentials(tc.ct, *tc.existing)
|
||
}
|
||
|
||
runner.AddRunner(e).Register(c)
|
||
err = c.MustCompile(t.Context(), tc.schema).Validate(bytes.NewBufferString(tc.doc))
|
||
if tc.expectErr != nil {
|
||
require.EqualError(t, err, tc.expectErr.Error())
|
||
return
|
||
}
|
||
require.NoError(t, err)
|
||
require.NoError(t, e.Finish())
|
||
|
||
credentials, ok := i.GetCredentials(tc.ct)
|
||
require.True(t, ok)
|
||
assert.ElementsMatch(t, tc.expectedIdentifiers, credentials.Identifiers)
|
||
snapshotx.SnapshotT(t, credentials, snapshotx.ExceptPaths("identifiers"))
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSchemaExtensionCredentialsCodeNoValidateAddress(t *testing.T) {
|
||
c := jsonschema.NewCompiler()
|
||
runner, err := schema.NewExtensionRunner(t.Context())
|
||
require.NoError(t, err)
|
||
|
||
i := new(identity.Identity)
|
||
e := identity.NewSchemaExtensionCredentials(i)
|
||
runner.AddRunner(e).Register(c)
|
||
|
||
require.NoError(t, c.MustCompile(t.Context(), "file://./stub/extension/credentials/code-no-validate.schema.json").
|
||
Validate(bytes.NewBufferString(`{"email":"FOO.@docomo.ne.jp"}`)))
|
||
require.NoError(t, e.Finish())
|
||
|
||
cred, ok := i.GetCredentials(identity.CredentialsTypeCodeAuth)
|
||
require.True(t, ok)
|
||
|
||
var conf identity.CredentialsCode
|
||
require.NoError(t, json.Unmarshal(cred.Config, &conf))
|
||
require.Len(t, conf.Addresses, 1)
|
||
// The stored address must equal what login-code normalization produces,
|
||
// or loginSendCode can never match it (strategy_login.go:521).
|
||
assert.Equal(t, x.GracefulNormalization("FOO.@docomo.ne.jp"), conf.Addresses[0].Address)
|
||
assert.Equal(t, identity.CodeChannelEmail, conf.Addresses[0].Channel)
|
||
}
|