snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

265
identity/validator.go Normal file
View File

@@ -0,0 +1,265 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package identity
import (
"context"
"maps"
"slices"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/x"
"github.com/ory/x/otelx"
)
type (
validatorDependencies interface {
schema.IdentitySchemaProvider
config.Provider
}
Validator struct {
v *schema.Validator
d validatorDependencies
}
ValidationProvider interface {
IdentityValidator() *Validator
}
)
func NewValidator(d validatorDependencies) *Validator {
return &Validator{v: schema.NewValidator(), d: d}
}
func (v *Validator) ValidateWithRunner(ctx context.Context, i *Identity, runners ...schema.ValidateExtension) error {
runner, err := schema.NewExtensionRunner(ctx, schema.WithValidateRunners(runners...))
if err != nil {
return err
}
ss, err := v.d.IdentityTraitsSchemas(ctx)
if err != nil {
return err
}
s, err := ss.GetByID(i.SchemaID)
if err != nil {
return err
}
if len(i.Traits) == 0 {
i.Traits = []byte(`{}`)
}
if err := v.normalizePhoneTraits(ctx, i, s.URL.String()); err != nil {
return err
}
traits, err := sjson.SetRawBytes([]byte(`{}`), "traits", i.Traits)
if err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithError(err.Error()))
}
return v.v.Validate(ctx, s.URL.String(), traits,
schema.WithExtensionRunner(runner),
schema.WithDisallowRefs(v.d.Config().SecurityDisallowRefInIdentitySchemas(ctx)),
)
}
// normalizePhoneTraits rewrites trait values used as phone-channel
// identifiers (code+sms credential identifiers, recovery via sms, or
// verification via sms) into the E.164 form Kratos stores in its side
// tables. Without this, webhook payloads templated against
// `identity.traits` see the raw user input while Kratos keys on the
// normalized identifier, producing inconsistent values across systems.
//
// Email-channel identifiers preserve case per the regression test for
// https://github.com/ory/kratos/issues/3187.
//
// Schema-load and compile errors are swallowed; the subsequent
// `v.v.Validate` call uses the same loader and compiler wiring and
// re-surfaces the same error with full herodot context. Context
// cancellation, however, must propagate so callers see the cancellation
// instead of a generic schema error from the second compile.
//
// WORKAROUND: this compiles the schema a second time and walks it from
// scratch because github.com/ory/jsonschema/v3 does not expose the JSON
// pointer of the value being validated to extension hooks. Once we move
// to santhosh-tekuri/jsonschema/v6, which provides
// ValidatorContext.ValueLocation(), this and walkPhoneTraits collapse
// into a single ValidateExtension that writes back to i.Traits inline.
// Tracking: ~/.claude/docs/cloud/plans/jsonschema-v6-migration.md.
func (v *Validator) normalizePhoneTraits(ctx context.Context, i *Identity, schemaURL string) error {
swallow := func(err error) error {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return nil
}
compiler, err := schema.NewCompilerWithURL(ctx, schemaURL,
v.d.Config().SecurityDisallowRefInIdentitySchemas(ctx))
if err != nil {
return swallow(err)
}
// Register populates each compiled *Schema's Extensions map with the
// parsed *ExtensionConfig that walkPhoneTraits reads. Without this
// call, the walk sees nil extensions and never normalizes.
runner, err := schema.NewExtensionRunner(ctx)
if err != nil {
return swallow(err)
}
runner.Register(compiler)
compiled, err := compiler.Compile(ctx, schemaURL)
if err != nil {
return swallow(err)
}
i.Traits = walkPhoneTraits(i.Traits, compiled.Properties["traits"], "", map[*jsonschema.Schema]struct{}{})
return nil
}
// walkPhoneTraits traverses the compiled schema tree and rewrites every
// trait leaf marked as a phone-channel identifier to its E.164 form.
//
// Properties extend the path with the property name; array items extend
// it with the index. Combinators (allOf, anyOf, oneOf, if/then/else,
// not, $ref) sit at the same path. PatternProperties and contains are
// not walked: identity schemas in practice keep credential identifiers
// on named or indexed positions.
//
// `stack` holds the schemas currently on the recursion path so true
// cycles short-circuit without preventing legitimate DAG reuse —
// e.g. two trait properties that share a `$ref` definition each get
// walked because the shared node is removed from the stack when the
// first walk returns. Pure-$ref cycles are already rejected by
// schema/prevalidate.go, but cycles with intermediate validation
// (a $ref back to an ancestor that has `properties`) reach the walk
// and need this guard.
func walkPhoneTraits(traits Traits, node *jsonschema.Schema, path string, stack map[*jsonschema.Schema]struct{}) Traits {
if node == nil {
return traits
}
if _, onStack := stack[node]; onStack {
return traits
}
stack[node] = struct{}{}
defer delete(stack, node)
if isPhoneIdentifier(node) {
return normalizePhoneAt(traits, path)
}
// Combinators evaluated at the same path.
for _, sub := range slices.Concat(node.AllOf, node.AnyOf, node.OneOf,
[]*jsonschema.Schema{node.Not, node.If, node.Then, node.Else, node.Ref}) {
traits = walkPhoneTraits(traits, sub, path, stack)
}
// Properties extend the path. Iterate in sorted order so the
// rewrite sequence is deterministic and tests are reproducible.
for _, name := range slices.Sorted(maps.Keys(node.Properties)) {
next := escapePathSegment(name)
if path != "" {
next = path + "." + next
}
traits = walkPhoneTraits(traits, node.Properties[name], next, stack)
}
// Array items. `Items` is nil, *Schema (homogeneous — every element
// validates against the same schema), or []*Schema (tuple — index N
// validates against the Nth schema). For homogeneous items we walk
// against the actual data because schema length is unbounded; for
// tuple items the schema length defines the slots to walk.
switch items := node.Items.(type) {
case *jsonschema.Schema:
index := 0
gjson.GetBytes(traits, path).ForEach(func(_, _ gjson.Result) bool {
next := strconv.Itoa(index)
if path != "" {
next = path + "." + next
}
traits = walkPhoneTraits(traits, items, next, stack)
index++
return true
})
case []*jsonschema.Schema:
for i, item := range items {
next := strconv.Itoa(i)
if path != "" {
next = path + "." + next
}
traits = walkPhoneTraits(traits, item, next, stack)
}
}
return traits
}
// normalizePhoneAt rewrites the string trait at `path` to its E.164
// form when present and parseable. It returns the input unchanged on
// any miss so the caller can chain it through a walk.
func normalizePhoneAt(traits Traits, path string) Traits {
value := gjson.GetBytes(traits, path)
if value.Type != gjson.String {
return traits
}
normalized, err := x.NormalizeIdentifier(value.String(), "sms")
if err != nil || normalized == value.String() {
return traits
}
if updated, err := sjson.SetBytes(traits, path, normalized); err == nil {
return updated
}
return traits
}
// escapePathSegment escapes characters that gjson/sjson treat as path
// metacharacters so a property like "phone.number" is addressed as the
// single key "phone.number" rather than as nested `phone` → `number`.
func escapePathSegment(segment string) string {
if !strings.ContainsAny(segment, `.*?\`) {
return segment
}
var b strings.Builder
b.Grow(len(segment) + 4)
for _, r := range segment {
switch r {
case '.', '*', '?', '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
// isPhoneIdentifier reports whether any identity-extension hook would
// normalize the value at this schema node as an E.164 phone number.
func isPhoneIdentifier(node *jsonschema.Schema) bool {
cfg, _ := node.Extensions[schema.ExtensionName].(*schema.ExtensionConfig)
if cfg == nil {
return false
}
return (cfg.Credentials.Code.Identifier && cfg.Credentials.Code.Via == "sms") ||
cfg.Recovery.Via == "sms" ||
cfg.Verification.Via == "sms"
}
func (v *Validator) Validate(ctx context.Context, i *Identity) error {
return otelx.WithSpan(ctx, "identity.Validator.Validate", func(ctx context.Context) error {
return v.ValidateWithRunner(ctx, i,
NewSchemaExtensionCredentials(i),
NewSchemaExtensionVerification(i, v.d.Config().SelfServiceFlowVerificationRequestLifespan(ctx)),
NewSchemaExtensionRecovery(i),
)
})
}