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
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:
38
schema/context.go
Normal file
38
schema/context.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
)
|
||||
|
||||
func ContextSetRoot(err *jsonschema.ValidationError, head string) *jsonschema.ValidationError {
|
||||
err.InstancePtr = prefixPointer(err.InstancePtr, head)
|
||||
|
||||
if err.Context != nil {
|
||||
switch ctx := err.Context.(type) {
|
||||
case *jsonschema.ValidationErrorContextRequired:
|
||||
for k, pointer := range ctx.Missing {
|
||||
ctx.Missing[k] = prefixPointer(pointer, head)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k := range err.Causes {
|
||||
err.Causes[k] = ContextSetRoot(err.Causes[k], head)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func prefixPointer(pointer, head string) string {
|
||||
if pointer == "#" || pointer == "#/" {
|
||||
pointer = "#/" + head
|
||||
} else {
|
||||
pointer = "#/" + head + "/" + strings.TrimPrefix(pointer, "#/")
|
||||
}
|
||||
return pointer
|
||||
}
|
||||
45
schema/context_test.go
Normal file
45
schema/context_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
)
|
||||
|
||||
func TestContextSetRoot(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
in jsonschema.ValidationError
|
||||
head string
|
||||
expect jsonschema.ValidationError
|
||||
}{
|
||||
{
|
||||
head: "traits",
|
||||
in: jsonschema.ValidationError{
|
||||
InstancePtr: "#/foo/bar",
|
||||
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/foo/bar/baz"}},
|
||||
Causes: []*jsonschema.ValidationError{{
|
||||
InstancePtr: "#/foo/bar",
|
||||
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/foo/bar/baz"}},
|
||||
}},
|
||||
},
|
||||
expect: jsonschema.ValidationError{
|
||||
InstancePtr: "#/traits/foo/bar",
|
||||
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/traits/foo/bar/baz"}},
|
||||
Causes: []*jsonschema.ValidationError{{
|
||||
InstancePtr: "#/traits/foo/bar",
|
||||
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/traits/foo/bar/baz"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
assert.EqualValues(t, tc.expect, *ContextSetRoot(&tc.in, tc.head))
|
||||
})
|
||||
}
|
||||
}
|
||||
424
schema/errors.go
Normal file
424
schema/errors.go
Normal file
@@ -0,0 +1,424 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
*jsonschema.ValidationError
|
||||
Messages text.Messages
|
||||
}
|
||||
|
||||
func NewRequiredError(missingPtr, missingFieldName string) error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: fmt.Sprintf("missing properties: %s", missingFieldName),
|
||||
InstancePtr: missingPtr,
|
||||
Context: &jsonschema.ValidationErrorContextRequired{
|
||||
Missing: []string{missingFieldName},
|
||||
},
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewValidationErrorRequired(missingFieldName)),
|
||||
})
|
||||
}
|
||||
|
||||
func NewTOTPVerifierWrongError(instancePtr string) error {
|
||||
t := text.NewErrorValidationTOTPVerifierWrong()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewWebAuthnVerifierWrongError(instancePtr string) error {
|
||||
t := text.NewErrorValidationWebAuthnVerifierWrong()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewDeviceAuthnVerifierWrongError(instancePtr string) error {
|
||||
t := text.NewErrorValidationDeviceAuthnVerifierWrong()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewDeviceAuthnRelaxedAttestationNoLongerValidError(instancePtr string) error {
|
||||
t := text.NewErrorValidationDeviceAuthnRelaxedAttestationNoLongerValid()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewDeviceAuthnKeyReenrollmentRequiredError(instancePtr string) error {
|
||||
t := text.NewErrorValidationDeviceAuthnKeyReenrollmentRequired()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewLookupAlreadyUsed() error {
|
||||
t := text.NewErrorValidationLookupAlreadyUsed()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
func NewErrorValidationLookupInvalid() error {
|
||||
t := text.NewErrorValidationLookupInvalid()
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: t.Text,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(t),
|
||||
})
|
||||
}
|
||||
|
||||
type ValidationErrorContextPasswordPolicyViolation struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (r *ValidationErrorContextPasswordPolicyViolation) AddContext(_, _ string) {}
|
||||
|
||||
func (r *ValidationErrorContextPasswordPolicyViolation) FinishInstanceContext() {}
|
||||
|
||||
func NewPasswordPolicyViolationError(instancePtr string, message *text.Message) error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: fmt.Sprintf("the password does not fulfill the password policy because: %s", message.Text),
|
||||
InstancePtr: instancePtr,
|
||||
Context: &ValidationErrorContextPasswordPolicyViolation{
|
||||
Reason: message.Text,
|
||||
},
|
||||
},
|
||||
Messages: new(text.Messages).Add(message),
|
||||
})
|
||||
}
|
||||
|
||||
func NewMissingIdentifierError() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: "could not find any identifiers",
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationIdentifierMissing()),
|
||||
})
|
||||
}
|
||||
|
||||
type ValidationErrorContextInvalidCredentialsError struct{}
|
||||
|
||||
func (r *ValidationErrorContextInvalidCredentialsError) AddContext(_, _ string) {}
|
||||
|
||||
func (r *ValidationErrorContextInvalidCredentialsError) FinishInstanceContext() {}
|
||||
|
||||
func NewInvalidCredentialsError() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewAccountNotFoundError() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: "this account does not exist or has no login method configured",
|
||||
InstancePtr: "#/identifier",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationAccountNotFound()),
|
||||
})
|
||||
}
|
||||
|
||||
type ValidationErrorContextDuplicateCredentialsError struct {
|
||||
AvailableCredentials []string `json:"available_credential_types"`
|
||||
AvailableOIDCProviders []string `json:"available_oidc_providers"`
|
||||
IdentifierHint string `json:"credential_identifier_hint"`
|
||||
}
|
||||
|
||||
func (r *ValidationErrorContextDuplicateCredentialsError) AddContext(_, _ string) {}
|
||||
|
||||
func (r *ValidationErrorContextDuplicateCredentialsError) FinishInstanceContext() {}
|
||||
|
||||
type DuplicateCredentialsHinter interface {
|
||||
AvailableCredentials() []string
|
||||
AvailableOIDCProviders() []string
|
||||
IdentifierHint() string
|
||||
HasHints() bool
|
||||
}
|
||||
|
||||
func NewDuplicateCredentialsError(err error) error {
|
||||
if hinter := DuplicateCredentialsHinter(nil); errors.As(err, &hinter) && hinter.HasHints() {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `an account with the same identifier (email, phone, username, ...) exists already`,
|
||||
InstancePtr: "#/",
|
||||
Context: &ValidationErrorContextDuplicateCredentialsError{
|
||||
AvailableCredentials: hinter.AvailableCredentials(),
|
||||
AvailableOIDCProviders: hinter.AvailableOIDCProviders(),
|
||||
IdentifierHint: hinter.IdentifierHint(),
|
||||
},
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationDuplicateCredentialsWithHints(hinter.AvailableCredentials(), hinter.AvailableOIDCProviders(), hinter.IdentifierHint())),
|
||||
})
|
||||
}
|
||||
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `an account with the same identifier (email, phone, username, ...) exists already`,
|
||||
InstancePtr: "#/",
|
||||
Context: &ValidationErrorContextDuplicateCredentialsError{},
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationDuplicateCredentials()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoLoginStrategyResponsible() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `could not find a strategy to login with`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationLoginNoStrategyFound()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoRegistrationStrategyResponsible() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `could not find a strategy to sign up with`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationRegistrationNoStrategyFound()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoSettingsStrategyResponsible() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `could not find a strategy to update settings with`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationSettingsNoStrategyFound()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoRecoveryStrategyResponsible() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `could not find a strategy to recover your account with`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationRecoveryNoStrategyFound()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoVerificationStrategyResponsible() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `could not find a strategy to verify your account with`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationVerificationNoStrategyFound()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewAddressNotVerifiedError() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `account address not yet verified`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationAddressNotVerified()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoTOTPDeviceRegistered() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `you have no TOTP device set up`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationNoTOTPDevice()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoLookupDefined() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `you have no backup recovery codes set up`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationNoLookup()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoWebAuthnRegistered() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `you have no WebAuthn device set up`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationNoWebAuthnDevice()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoDeviceAuthnRegistered() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `you have no DeviceAuthn device set up`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationNoDeviceAuthnDevice()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewHookValidationError(instancePtr, message string, messages text.Messages) *ValidationError {
|
||||
return &ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: message,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationListError struct {
|
||||
Validations []*ValidationError
|
||||
}
|
||||
|
||||
func (e ValidationListError) Error() string {
|
||||
var detailError string
|
||||
for pos, validationErr := range e.Validations {
|
||||
detailError = detailError + fmt.Sprintf("\n(%d) %s", pos, validationErr.Error())
|
||||
}
|
||||
return fmt.Sprintf("%d validation errors occurred:%s", len(e.Validations), detailError)
|
||||
}
|
||||
|
||||
func (e *ValidationListError) Add(v *ValidationError) {
|
||||
e.Validations = append(e.Validations, v)
|
||||
}
|
||||
|
||||
func (e ValidationListError) HasErrors() bool {
|
||||
return len(e.Validations) > 0
|
||||
}
|
||||
|
||||
func (e *ValidationListError) WithError(instancePtr, message string, details text.Messages) {
|
||||
e.Validations = append(e.Validations, &ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: message,
|
||||
InstancePtr: instancePtr,
|
||||
},
|
||||
Messages: details,
|
||||
})
|
||||
}
|
||||
|
||||
func NewValidationListError(errs []*ValidationError) error {
|
||||
return errors.WithStack(&ValidationListError{Validations: errs})
|
||||
}
|
||||
|
||||
func NewNoWebAuthnCredentials() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `account does not exist or has no security key set up`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationSuchNoWebAuthnUser()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoCodeAuthnCredentials() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `account does not exist or has not setup up sign in with code`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationNoCodeUser()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewTraitsMismatch() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the submitted form data has changed from the previous submission`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationTraitsMismatch()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewRegistrationCodeInvalid() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the provided code is invalid or has already been used`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationRegistrationCodeInvalidOrAlreadyUsed()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewLoginCodeInvalid() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the provided code is invalid or has already been used`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationLoginCodeInvalidOrAlreadyUsed()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewLinkedCredentialsDoNotMatch() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `linked credentials do not match; please start a new flow`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationLoginLinkedCredentialsDoNotMatch()),
|
||||
})
|
||||
}
|
||||
|
||||
func NewUnknownAddressError() error {
|
||||
return errors.WithStack(&ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the supplied address does not match any known addresses.`,
|
||||
InstancePtr: "#/",
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationAddressUnknown()),
|
||||
},
|
||||
)
|
||||
}
|
||||
39
schema/errors_test.go
Normal file
39
schema/errors_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
func TestListValidationErrors(t *testing.T) {
|
||||
testErr := ValidationListError{}
|
||||
|
||||
assert.False(t, testErr.HasErrors())
|
||||
|
||||
testErr.WithError("#/traits/password", "error message", new(text.Messages).Add(text.NewErrorValidationDuplicateCredentials()))
|
||||
assert.True(t, testErr.HasErrors())
|
||||
assert.Len(t, testErr.Validations, 1)
|
||||
|
||||
validationError := &ValidationError{
|
||||
ValidationError: &jsonschema.ValidationError{
|
||||
Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`,
|
||||
InstancePtr: "#/",
|
||||
Context: &ValidationErrorContextPasswordPolicyViolation{},
|
||||
},
|
||||
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()),
|
||||
}
|
||||
testErr.Add(validationError)
|
||||
assert.Len(t, testErr.Validations, 2)
|
||||
assert.Equal(t, "2 validation errors occurred:"+
|
||||
"\n(0) I[#/traits/password] S[] error message"+
|
||||
"\n(1) I[#/] S[] the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number",
|
||||
testErr.Error())
|
||||
}
|
||||
184
schema/extension.go
Normal file
184
schema/extension.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/kratos/embedx"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
)
|
||||
|
||||
const (
|
||||
ExtensionName string = "ory.sh/kratos"
|
||||
)
|
||||
|
||||
type (
|
||||
ExtensionConfig struct {
|
||||
Credentials struct {
|
||||
Password struct {
|
||||
Identifier bool `json:"identifier"`
|
||||
} `json:"password"`
|
||||
WebAuthn struct {
|
||||
Identifier bool `json:"identifier"`
|
||||
} `json:"webauthn"`
|
||||
Passkey struct {
|
||||
DisplayName bool `json:"display_name"`
|
||||
} `json:"passkey"`
|
||||
TOTP struct {
|
||||
AccountName bool `json:"account_name"`
|
||||
} `json:"totp"`
|
||||
Code struct {
|
||||
Identifier bool `json:"identifier"`
|
||||
Via string `json:"via"`
|
||||
} `json:"code"`
|
||||
} `json:"credentials"`
|
||||
Verification struct {
|
||||
Via string `json:"via"`
|
||||
} `json:"verification"`
|
||||
Recovery struct {
|
||||
Via string `json:"via"`
|
||||
} `json:"recovery"`
|
||||
Organization struct {
|
||||
Matcher string `json:"matcher"`
|
||||
} `json:"organizations"`
|
||||
RawSchema map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
ValidateExtension interface {
|
||||
Run(ctx jsonschema.ValidationContext, config ExtensionConfig, value interface{}) error
|
||||
Finish() error
|
||||
}
|
||||
CompileExtension interface {
|
||||
Run(ctx jsonschema.CompilerContext, config ExtensionConfig, rawSchema map[string]interface{}) error
|
||||
}
|
||||
|
||||
ExtensionRunner struct {
|
||||
meta *jsonschema.Schema
|
||||
compile func(ctx jsonschema.CompilerContext, rawSchema map[string]interface{}) (interface{}, error)
|
||||
validate func(ctx jsonschema.ValidationContext, s interface{}, v interface{}) error
|
||||
|
||||
validateRunners []ValidateExtension
|
||||
compileRunners []CompileExtension
|
||||
}
|
||||
|
||||
ExtensionRunnerOption func(*ExtensionRunner)
|
||||
)
|
||||
|
||||
func (e *ExtensionConfig) EnhancePath(path jsonschemax.Path) map[string]any {
|
||||
props := path.CustomProperties
|
||||
if props == nil {
|
||||
props = make(map[string]any)
|
||||
}
|
||||
props[ExtensionName] = e
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
func WithValidateRunners(runners ...ValidateExtension) ExtensionRunnerOption {
|
||||
return func(r *ExtensionRunner) {
|
||||
r.validateRunners = append(r.validateRunners, runners...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithCompileRunners(runners ...CompileExtension) ExtensionRunnerOption {
|
||||
return func(r *ExtensionRunner) {
|
||||
r.compileRunners = append(r.compileRunners, runners...)
|
||||
}
|
||||
}
|
||||
|
||||
func NewExtensionRunner(ctx context.Context, opts ...ExtensionRunnerOption) (*ExtensionRunner, error) {
|
||||
var err error
|
||||
r := new(ExtensionRunner)
|
||||
// The extension runner compiles the bundled identity-extension meta
|
||||
// schema, which contains no external refs. The disallow flag has no
|
||||
// effect here, but we pass false to keep behavior strictly equivalent
|
||||
// to the legacy jsonschema.NewCompiler().
|
||||
c := NewCompiler(false)
|
||||
|
||||
if err = embedx.AddSchemaResources(c, embedx.IdentityExtension); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
r.meta, err = c.Compile(ctx, embedx.IdentityExtension.GetSchemaID())
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
r.compile = func(ctx jsonschema.CompilerContext, m map[string]interface{}) (interface{}, error) {
|
||||
if raw, ok := m[ExtensionName]; ok {
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(raw); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
var e ExtensionConfig
|
||||
if err := json.NewDecoder(&b).Decode(&e); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for _, runner := range r.compileRunners {
|
||||
if err := runner.Run(ctx, e, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
e.RawSchema = m
|
||||
|
||||
return &e, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
r.validate = func(ctx jsonschema.ValidationContext, s interface{}, v interface{}) error {
|
||||
c, ok := s.(*ExtensionConfig)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, runner := range r.validateRunners {
|
||||
if err := runner.Run(ctx, *c, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRunner) Register(compiler *jsonschema.Compiler) *ExtensionRunner {
|
||||
compiler.Extensions[ExtensionName] = r.Extension()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *ExtensionRunner) Extension() jsonschema.Extension {
|
||||
return jsonschema.Extension{
|
||||
Meta: r.meta,
|
||||
Compile: r.compile,
|
||||
Validate: r.validate,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRunner) AddRunner(run ValidateExtension) *ExtensionRunner {
|
||||
r.validateRunners = append(r.validateRunners, run)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *ExtensionRunner) Finish() error {
|
||||
for _, runner := range r.validateRunners {
|
||||
if err := runner.Finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
89
schema/extension_test.go
Normal file
89
schema/extension_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
_ "github.com/ory/jsonschema/v3/fileloader"
|
||||
)
|
||||
|
||||
type extensionStub struct {
|
||||
identifiers []string
|
||||
accountNames []string
|
||||
}
|
||||
|
||||
func (r *extensionStub) Run(ctx jsonschema.ValidationContext, config ExtensionConfig, value interface{}) error {
|
||||
if config.Credentials.Password.Identifier {
|
||||
r.identifiers = append(r.identifiers, fmt.Sprintf("%s", value))
|
||||
}
|
||||
if config.Credentials.TOTP.AccountName {
|
||||
r.accountNames = append(r.accountNames, fmt.Sprintf("%s", value))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionStub) Finish() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func TestExtensionRunner(t *testing.T) {
|
||||
t.Run("method=get identifier", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
expectErr error
|
||||
schema string
|
||||
doc string
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
doc: `{"email":"foo@ory.sh"}`,
|
||||
schema: "file://./stub/extension/schema.json",
|
||||
expect: []string{"foo@ory.sh"},
|
||||
},
|
||||
{
|
||||
doc: `{"emails":["foo@ory.sh","bar@ory.sh"]}`,
|
||||
schema: "file://./stub/extension/schema.nested.json",
|
||||
expect: []string{"foo@ory.sh", "bar@ory.sh"},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
c := jsonschema.NewCompiler()
|
||||
runner, err := NewExtensionRunner(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := new(extensionStub)
|
||||
runner.AddRunner(r).Register(c)
|
||||
|
||||
err = c.MustCompile(ctx, tc.schema).Validate(bytes.NewBufferString(tc.doc))
|
||||
if tc.expectErr != nil {
|
||||
require.EqualError(t, err, tc.expectErr.Error())
|
||||
}
|
||||
|
||||
assert.EqualValues(t, tc.expect, r.identifiers)
|
||||
assert.EqualValues(t, tc.expect, r.accountNames)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=applies meta schema", func(t *testing.T) {
|
||||
c := jsonschema.NewCompiler()
|
||||
runner, err := NewExtensionRunner(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner.Register(c)
|
||||
|
||||
_, err = c.Compile(ctx, "file://./stub/extension/invalid.schema.json")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expected boolean, but got number")
|
||||
})
|
||||
}
|
||||
265
schema/handler.go
Normal file
265
schema/handler.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/x/errorsx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/pagination/migrationpagination"
|
||||
)
|
||||
|
||||
type (
|
||||
handlerDependencies interface {
|
||||
httpx.WriterProvider
|
||||
logrusx.Provider
|
||||
IdentitySchemaProvider
|
||||
nosurfx.CSRFProvider
|
||||
config.Provider
|
||||
otelx.Provider
|
||||
httpx.ClientProvider
|
||||
}
|
||||
Handler struct {
|
||||
r handlerDependencies
|
||||
}
|
||||
HandlerProvider interface {
|
||||
SchemaHandler() *Handler
|
||||
}
|
||||
)
|
||||
|
||||
func NewHandler(r handlerDependencies) *Handler {
|
||||
return &Handler{r: r}
|
||||
}
|
||||
|
||||
const (
|
||||
SchemasPath string = "schemas"
|
||||
maxSchemaSize = 1024 * 1024 // 1 MB
|
||||
)
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
|
||||
h.r.CSRFHandler().IgnoreGlobs(
|
||||
"/"+SchemasPath+"/*",
|
||||
httprouterx.AdminPrefix+"/"+SchemasPath+"/*",
|
||||
)
|
||||
public.GET(fmt.Sprintf("/%s/{id}", SchemasPath), h.getIdentitySchema)
|
||||
public.GET(fmt.Sprintf("/%s", SchemasPath), h.getAll)
|
||||
public.GET(fmt.Sprintf("%s/%s/{id}", httprouterx.AdminPrefix, SchemasPath), h.getIdentitySchema)
|
||||
public.GET(fmt.Sprintf("%s/%s", httprouterx.AdminPrefix, SchemasPath), h.getAll)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
|
||||
admin.GET(fmt.Sprintf("/%s/{id}", SchemasPath), redir.RedirectToPublicRoute(h.r))
|
||||
admin.GET(fmt.Sprintf("/%s", SchemasPath), redir.RedirectToPublicRoute(h.r))
|
||||
}
|
||||
|
||||
// Raw JSON Schema
|
||||
//
|
||||
// swagger:model identitySchema
|
||||
type _ json.RawMessage
|
||||
|
||||
// Get Identity JSON Schema Response
|
||||
//
|
||||
// swagger:parameters getIdentitySchema
|
||||
type _ struct {
|
||||
// ID must be set to the ID of schema you want to get
|
||||
//
|
||||
// required: true
|
||||
// in: path
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// swagger:route GET /schemas/{id} identity getIdentitySchema
|
||||
//
|
||||
// # Get Identity JSON Schema
|
||||
//
|
||||
// Return a specific identity schema.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: identitySchema
|
||||
// 404: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-admin-medium
|
||||
func (h *Handler) getIdentitySchema(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := h.r.Tracer(r.Context()).Tracer().Start(r.Context(), "schema.Handler.getIdentitySchema")
|
||||
defer span.End()
|
||||
|
||||
ss, err := h.r.IdentityTraitsSchemas(ctx)
|
||||
if err != nil {
|
||||
h.r.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
s, err := ss.GetByID(id)
|
||||
if err != nil {
|
||||
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound().WithReasonf("Identity schema `%s` could not be found.", id)))
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := h.ReadSchema(ctx, s.URL)
|
||||
if err != nil {
|
||||
code, ok := errorsx.GetCodeFromHerodotError(err)
|
||||
|
||||
if errors.Is(err, fs.ErrNotExist) || (ok && code == http.StatusNotFound) {
|
||||
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrMisconfiguration().WithReason("The file for this JSON Schema ID could not be found/fetched. This is a configuration issue.").WithDebugf("%+v", err)))
|
||||
} else if ok && code == http.StatusBadGateway {
|
||||
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrUpstreamError().WithReason("The file for this JSON Schema ID could not be fetched. This is an upstream issue.").WithDebugf("%+v", err)))
|
||||
} else {
|
||||
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError().WithReason("The file for this JSON Schema ID could not be read. This is an I/O issue.").WithDebugf("%+v", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
h.r.Writer().Write(w, r, json.RawMessage(raw))
|
||||
}
|
||||
|
||||
// List of Identity JSON Schemas
|
||||
//
|
||||
// swagger:model identitySchemas
|
||||
type IdentitySchemas []identitySchemaContainer
|
||||
|
||||
// An Identity JSON Schema Container
|
||||
//
|
||||
// swagger:model identitySchemaContainer
|
||||
type identitySchemaContainer struct {
|
||||
// The ID of the Identity JSON Schema
|
||||
// required: true
|
||||
ID string `json:"id"`
|
||||
// The actual Identity JSON Schema
|
||||
// required: true
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
}
|
||||
|
||||
// List Identity JSON Schemas Response
|
||||
//
|
||||
// swagger:parameters listIdentitySchemas
|
||||
type _ struct {
|
||||
migrationpagination.RequestParameters
|
||||
}
|
||||
|
||||
// List Identity JSON Schemas Response
|
||||
//
|
||||
// swagger:response identitySchemas
|
||||
type _ struct {
|
||||
migrationpagination.ResponseHeaderAnnotation
|
||||
|
||||
// in: body
|
||||
Body IdentitySchemas
|
||||
}
|
||||
|
||||
// swagger:route GET /schemas identity listIdentitySchemas
|
||||
//
|
||||
// # Get all Identity Schemas
|
||||
//
|
||||
// Returns a list of all identity schemas currently in use.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: identitySchemas
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-admin-medium
|
||||
func (h *Handler) getAll(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := h.r.Tracer(r.Context()).Tracer().Start(r.Context(), "schema.Handler.getAll")
|
||||
defer span.End()
|
||||
|
||||
page, itemsPerPage := x.ParsePagination(r)
|
||||
|
||||
allSchemas, err := h.r.IdentityTraitsSchemas(r.Context())
|
||||
if err != nil {
|
||||
h.r.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
total := allSchemas.Total()
|
||||
schemas := allSchemas.List(page, itemsPerPage)
|
||||
|
||||
ss := make(IdentitySchemas, len(schemas))
|
||||
for i, schema := range schemas {
|
||||
raw, err := h.ReadSchema(ctx, schema.URL)
|
||||
if err != nil {
|
||||
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("The file for a JSON Schema ID could not be found or opened. This is a configuration issue.").WithWrap(err)))
|
||||
return
|
||||
}
|
||||
ss[i] = identitySchemaContainer{
|
||||
ID: schema.ID,
|
||||
Schema: raw,
|
||||
}
|
||||
}
|
||||
|
||||
x.PaginationHeader(w, *r.URL, int64(total), page, itemsPerPage)
|
||||
h.r.Writer().Write(w, r, ss)
|
||||
}
|
||||
|
||||
func (h *Handler) ReadSchema(ctx context.Context, uri *url.URL) (data []byte, err error) {
|
||||
ctx, span := h.r.Tracer(ctx).Tracer().Start(ctx, "schema.Handler.ReadSchema")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
switch uri.Scheme {
|
||||
case "file":
|
||||
data, err = os.ReadFile(uri.Host + uri.Path) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(fmt.Errorf("could not read schema file: %w", err))
|
||||
}
|
||||
case "base64":
|
||||
data, err = base64.StdEncoding.DecodeString(strings.TrimPrefix(uri.String(), "base64://"))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(fmt.Errorf("could not decode schema file: %w", err))
|
||||
}
|
||||
default:
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(fmt.Errorf("could not create request: %w", err))
|
||||
}
|
||||
resp, err := h.r.HTTPClient(ctx).Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrUpstreamError().WithReason("could not fetch schema").WithError(err.Error()).WithDetail("uri", uri))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, herodot.ErrNotFound().WithDetail("url", uri)
|
||||
}
|
||||
return nil, errors.WithStack(herodot.ErrUpstreamError().WithError("upstream error").WithDetail("status_code", resp.StatusCode).WithDetail("uri", uri))
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(resp.Body, maxSchemaSize))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrUpstreamError().WithReason("could not read schema response").WithError(err.Error()).WithDetail("uri", uri))
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
232
schema/handler_test.go
Normal file
232
schema/handler_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/client-go"
|
||||
_ "github.com/ory/jsonschema/v3/fileloader"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
ts := contextx.NewConfigurableTestServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /identity.schema.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
file, err := os.Open("./stub/identity.schema.json")
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(w, file)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
mux.HandleFunc("GET /500", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
})
|
||||
fileServer := httptest.NewServer(mux)
|
||||
t.Cleanup(fileServer.Close)
|
||||
|
||||
schemas := map[string]struct {
|
||||
uri string
|
||||
getRaw func() ([]byte, error)
|
||||
expectedHttpResponseCode int
|
||||
}{
|
||||
"default": {
|
||||
uri: "file://./stub/identity.schema.json",
|
||||
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity.schema.json") },
|
||||
expectedHttpResponseCode: http.StatusOK,
|
||||
},
|
||||
"identity2": {
|
||||
uri: "file://./stub/identity-2.schema.json",
|
||||
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity-2.schema.json") },
|
||||
expectedHttpResponseCode: http.StatusOK,
|
||||
},
|
||||
"base64": {
|
||||
uri: "base64://ewogICIkc2NoZW1hIjogImh0dHA6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQtMDcvc2NoZW1hIyIsCiAgInR5cGUiOiAib2JqZWN0IiwKICAicHJvcGVydGllcyI6IHsKICAgICJiYXIiOiB7CiAgICAgICJ0eXBlIjogInN0cmluZyIKICAgIH0KICB9LAogICJyZXF1aXJlZCI6IFsKICAgICJiYXIiCiAgXQp9",
|
||||
getRaw: func() ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString("ewogICIkc2NoZW1hIjogImh0dHA6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQtMDcvc2NoZW1hIyIsCiAgInR5cGUiOiAib2JqZWN0IiwKICAicHJvcGVydGllcyI6IHsKICAgICJiYXIiOiB7CiAgICAgICJ0eXBlIjogInN0cmluZyIKICAgIH0KICB9LAogICJyZXF1aXJlZCI6IFsKICAgICJiYXIiCiAgXQp9")
|
||||
},
|
||||
expectedHttpResponseCode: http.StatusOK,
|
||||
},
|
||||
"unreachable": {
|
||||
uri: "http://127.0.0.1:12345/unreachable-schema",
|
||||
getRaw: func() ([]byte, error) {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
},
|
||||
expectedHttpResponseCode: http.StatusBadGateway,
|
||||
},
|
||||
"no-file": {
|
||||
uri: "file://./stub/does-not-exist.schema.json",
|
||||
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("no such file or directory") },
|
||||
expectedHttpResponseCode: http.StatusInternalServerError,
|
||||
},
|
||||
"directory": {
|
||||
uri: "file://./stub",
|
||||
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("is a directory") },
|
||||
// On an existing directory, `open(2)` succeeds but `read(2)` fails so it looks like an I/O error.
|
||||
expectedHttpResponseCode: http.StatusInternalServerError,
|
||||
},
|
||||
"file-network": {
|
||||
uri: fileServer.URL + "/identity.schema.json",
|
||||
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity.schema.json") },
|
||||
expectedHttpResponseCode: http.StatusOK,
|
||||
},
|
||||
"file-network-not-found": {
|
||||
uri: fileServer.URL + "/not-found",
|
||||
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("could not be found") },
|
||||
expectedHttpResponseCode: http.StatusInternalServerError,
|
||||
},
|
||||
"file-network-500": {
|
||||
uri: fileServer.URL + "/500",
|
||||
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("giving up") },
|
||||
expectedHttpResponseCode: http.StatusBadGateway,
|
||||
},
|
||||
"preset://email": {
|
||||
uri: "file://./stub/identity-2.schema.json",
|
||||
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity-2.schema.json") },
|
||||
expectedHttpResponseCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
configSchemas := make(config.Schemas, 0, len(schemas))
|
||||
for id, s := range schemas {
|
||||
configSchemas = append(configSchemas, config.Schema{
|
||||
ID: id,
|
||||
URL: s.uri,
|
||||
})
|
||||
}
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
|
||||
config.ViperKeyPublicBaseURL: ts.URL,
|
||||
config.ViperKeyDefaultIdentitySchemaID: "default",
|
||||
config.ViperKeyIdentitySchemas: configSchemas,
|
||||
}))
|
||||
reg.SchemaHandler().RegisterPublicRoutes(router)
|
||||
|
||||
getReq := func(ctx context.Context, t *testing.T, path string, expectCode int) []byte {
|
||||
res, err := ts.Client(ctx).Get(ts.URL + path)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
require.EqualValuesf(t, expectCode, res.StatusCode, "%s", body)
|
||||
return body
|
||||
}
|
||||
|
||||
for id, s := range schemas {
|
||||
t.Run(fmt.Sprintf("case=get %s schema", id), func(t *testing.T) {
|
||||
|
||||
_, err := s.getRaw()
|
||||
actual := getReq(t.Context(), t, fmt.Sprintf("/schemas/%s", url.PathEscape(id)), s.expectedHttpResponseCode)
|
||||
require.True(t, json.Valid(actual), string(actual))
|
||||
|
||||
switch s.expectedHttpResponseCode {
|
||||
case http.StatusOK:
|
||||
require.NoError(t, err)
|
||||
case http.StatusInternalServerError, http.StatusBadGateway:
|
||||
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=get schema with base64 encoded ID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected, err := schemas["preset://email"].getRaw()
|
||||
require.NoError(t, err)
|
||||
|
||||
actual := getReq(t.Context(), t, "/schemas/"+base64.RawURLEncoding.EncodeToString([]byte("preset://email")), http.StatusOK)
|
||||
require.JSONEq(t, string(expected), string(actual))
|
||||
})
|
||||
|
||||
t.Run("case=get all schemas", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaultSchema, err := configSchemas.FindSchemaByID("default")
|
||||
require.NoError(t, err)
|
||||
identity2Schema, err := configSchemas.FindSchemaByID("identity2")
|
||||
require.NoError(t, err)
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{*defaultSchema, *identity2Schema})
|
||||
|
||||
getSchemasPaginated := func(t *testing.T, page, perPage, expectCode int) []byte {
|
||||
return getReq(ctx, t, fmt.Sprintf("/schemas?page=%d&per_page=%d", page, perPage), expectCode)
|
||||
}
|
||||
|
||||
body := getSchemasPaginated(t, 0, 10, http.StatusOK)
|
||||
|
||||
var result []client.IdentitySchemaContainer
|
||||
require.NoErrorf(t, json.Unmarshal(body, &result), "%s", body)
|
||||
|
||||
var actualIDs []string
|
||||
for _, s := range result {
|
||||
actualIDs = append(actualIDs, s.Id)
|
||||
}
|
||||
assert.Equal(t, []string{defaultSchema.ID, identity2Schema.ID}, actualIDs)
|
||||
|
||||
assertCorrectSchema := func(t *testing.T, r client.IdentitySchemaContainer) {
|
||||
expected, err := schemas[r.Id].getRaw()
|
||||
require.NoError(t, err)
|
||||
actual, err := json.Marshal(r.Schema)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expected), string(actual))
|
||||
}
|
||||
|
||||
for _, r := range result {
|
||||
assertCorrectSchema(t, r)
|
||||
}
|
||||
|
||||
for page := range 2 {
|
||||
t.Run(fmt.Sprintf("page=%d", page), func(t *testing.T) {
|
||||
body := getSchemasPaginated(t, page, 1, http.StatusOK)
|
||||
|
||||
var result []client.IdentitySchemaContainer
|
||||
require.NoError(t, json.Unmarshal(body, &result))
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, actualIDs[page], result[0].Id)
|
||||
assertCorrectSchema(t, result[0])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=read schema", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range schemas {
|
||||
expected, expectedErr := s.getRaw()
|
||||
|
||||
actual, err := reg.SchemaHandler().ReadSchema(t.Context(), urlx.ParseOrPanic(s.uri))
|
||||
if expectedErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, expectedErr.Error()) // not using error.is because some of the errors are not accessible
|
||||
}
|
||||
|
||||
if expectedErr == nil {
|
||||
require.JSONEq(t, string(expected), string(actual))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
108
schema/loader.go
Normal file
108
schema/loader.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
_ "github.com/ory/jsonschema/v3/base64loader"
|
||||
_ "github.com/ory/jsonschema/v3/fileloader"
|
||||
"github.com/ory/jsonschema/v3/httploader"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
// loadRefURL resolves the URL of a `$ref` inside a schema. It enforces the
|
||||
// scheme allowlist and then delegates to the jsonschema package's global
|
||||
// loader table. The global `file` loader remains registered so that
|
||||
// operator-configured top-level schema URLs (resolved outside the compiler)
|
||||
// keep working.
|
||||
func loadRefURL(ctx context.Context, raw string) (io.ReadCloser, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid $ref URL %q: %w", raw, err)
|
||||
}
|
||||
if u.Scheme != "base64" {
|
||||
return nil, fmt.Errorf("$ref scheme %q is not permitted in identity schemas", u.Scheme)
|
||||
}
|
||||
return jsonschema.LoadURL(ctx, raw)
|
||||
}
|
||||
|
||||
// NewCompiler returns a jsonschema.Compiler. When disallowRefs is true, the
|
||||
// compiler rejects `file://` URLs (and any other non-allowlisted scheme) in
|
||||
// `$ref` values, preventing an attacker-supplied schema from reading local
|
||||
// files on the Kratos host. When disallowRefs is false, the compiler uses
|
||||
// the jsonschema library's default loader table, which preserves legacy
|
||||
// behavior for operators who intentionally reference local files.
|
||||
//
|
||||
// The flag is controlled by `security.disallow_ref_in_identity_schemas`.
|
||||
// Ory Network forces it on.
|
||||
func NewCompiler(disallowRefs bool) *jsonschema.Compiler {
|
||||
c := jsonschema.NewCompiler()
|
||||
if disallowRefs {
|
||||
c.LoadURL = loadRefURL
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// NewCompilerWithURL returns a NewCompiler with the top-level schema at
|
||||
// schemaURL already loaded and registered via the trusted global loader
|
||||
// (which always supports `file://` for operator-configured top-level URLs,
|
||||
// regardless of disallowRefs). See NewCompiler for the semantics of
|
||||
// disallowRefs.
|
||||
func NewCompilerWithURL(ctx context.Context, schemaURL string, disallowRefs bool) (*jsonschema.Compiler, error) {
|
||||
ctx = ensureGuardedHTTPClient(ctx)
|
||||
|
||||
resource, err := jsonschema.LoadURL(ctx, schemaURL)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
defer func() { _ = resource.Close() }()
|
||||
|
||||
// Read the body with a hard size cap so a malicious schema URL cannot
|
||||
// OOM kratos by returning a multi-GB body.
|
||||
data, err := io.ReadAll(io.LimitReader(resource, MaxSchemaBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
if len(data) > MaxSchemaBodyBytes {
|
||||
return nil, errors.Errorf("identity schema rejected: body exceeds %d bytes", MaxSchemaBodyBytes)
|
||||
}
|
||||
|
||||
// Decode once for structural prevalidation. The upstream compiler will
|
||||
// decode again from the same bytes — accepting a small CPU duplication
|
||||
// in exchange for the security gate.
|
||||
var doc any
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
if err := preValidateSchema(doc); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
c := NewCompiler(disallowRefs)
|
||||
if err := c.AddResource(schemaURL, bytes.NewReader(data)); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var defaultHTTPClient = httpx.NewResilientClient(httpx.ResilientClientDisallowInternalIPs())
|
||||
|
||||
// ensureGuardedHTTPClient guarantees that the jsonschema httploader will
|
||||
// pick up a client whose dialer rejects internal IP ranges.
|
||||
func ensureGuardedHTTPClient(ctx context.Context) context.Context {
|
||||
if ctx.Value(httploader.ContextKey) != nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, httploader.ContextKey, defaultHTTPClient)
|
||||
}
|
||||
173
schema/prevalidate.go
Normal file
173
schema/prevalidate.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxSchemaBodyBytes caps the raw byte size of an identity schema fetched
|
||||
// from any source. Documents above this size cannot be cached or compiled.
|
||||
// The cap protects against memory exhaustion from a customer-controlled URL
|
||||
// returning an arbitrarily large body. It is independent of the schema's
|
||||
// internal structure: realistic identity schemas are well under this limit.
|
||||
const MaxSchemaBodyBytes = 1 << 20 // 1 MiB.
|
||||
|
||||
// preValidateSchema walks a parsed identity schema document and rejects
|
||||
// patterns that would let a customer-supplied schema crash kratos at
|
||||
// compile or validate time.
|
||||
//
|
||||
// Specifically, this function rejects:
|
||||
//
|
||||
// - Any cycle in the document's `$ref` chain graph: a sequence of `$ref`
|
||||
// nodes P₀ → P₁ → … → Pₙ → P₀ in which every step is a `$ref`. The
|
||||
// upstream compiler memoizes ref resolution to terminate compilation,
|
||||
// but the resulting `*Schema` graph contains the cycle, and at
|
||||
// validate time `s.Ref.validate(v)` recurses indefinitely and crashes
|
||||
// the process via fatal stack overflow (jsonschema/v3
|
||||
// schema.go:155-169 has no validate-time cycle guard, and stack
|
||||
// overflow is unrecoverable).
|
||||
// - A `pattern` or `patternProperties` key whose value is not a valid
|
||||
// regular expression — the upstream compiler uses regexp.MustCompile,
|
||||
// which panics, and the compile path has no panic recovery.
|
||||
//
|
||||
// Cycles whose intermediate nodes have non-`$ref` validation (e.g. a
|
||||
// `$ref` to an ancestor that contains a `properties` map) are NOT
|
||||
// rejected: each cycle iteration consumes at least one level of input, so
|
||||
// recursion is bounded by JSON parser depth. Detecting the dangerous
|
||||
// pure-ref subgraph is the goal.
|
||||
//
|
||||
// This function does not bound the schema's structural size (nesting
|
||||
// depth, key count, array arity). Realistic identity schemas vary widely,
|
||||
// and bounding them at the loader layer rejected legitimate documents.
|
||||
// The body size cap (MaxSchemaBodyBytes) plus Go stdlib's JSON nesting
|
||||
// limit (10000) provide the structural backstop.
|
||||
func preValidateSchema(doc any) error {
|
||||
v := &preValidator{refs: map[string]string{}}
|
||||
if err := v.walk(doc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.detectRefCycles()
|
||||
}
|
||||
|
||||
type preValidator struct {
|
||||
// refs maps each `$ref` location's JSON-pointer path to its target
|
||||
// JSON-pointer path. Targets that cannot be resolved as in-document
|
||||
// fragments (external URLs, malformed refs) are excluded — those go
|
||||
// through the loadRefURL scheme allowlist.
|
||||
refs map[string]string
|
||||
}
|
||||
|
||||
func (p *preValidator) walk(v any, path string) error {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
// Record `$ref` for cycle detection in detectRefCycles. Root
|
||||
// pointers (`#`, `#/`, empty) map to the empty path. Anything
|
||||
// without a `#/` prefix is external — out of scope here;
|
||||
// loadRefURL handles scheme enforcement.
|
||||
if ref, ok := v["$ref"].(string); ok {
|
||||
switch {
|
||||
case ref == "" || ref == "#" || ref == "#/":
|
||||
p.refs[path] = ""
|
||||
case strings.HasPrefix(ref, "#/"):
|
||||
p.refs[path] = strings.TrimPrefix(ref, "#")
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compile `pattern` regexes so an invalid one returns a
|
||||
// kratos-side error instead of panicking deep in
|
||||
// regexp.MustCompile during the upstream compile.
|
||||
if pat, ok := v["pattern"].(string); ok {
|
||||
if _, err := regexp.Compile(pat); err != nil {
|
||||
return fmt.Errorf("identity schema rejected: invalid regex in pattern: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// patternProperties keys are themselves regexes.
|
||||
if patternProps, ok := v["patternProperties"].(map[string]any); ok {
|
||||
for raw := range patternProps {
|
||||
if _, err := regexp.Compile(raw); err != nil {
|
||||
return fmt.Errorf("identity schema rejected: invalid regex in patternProperties key %q: %w", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, sub := range v {
|
||||
if err := p.walk(sub, path+"/"+escapeJSONPointer(k)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case []any:
|
||||
for i, sub := range v {
|
||||
if err := p.walk(sub, path+"/"+strconv.Itoa(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectRefCycles reports an error if the document's `$ref` chain graph
|
||||
// contains a cycle. Walking from each `$ref` location, follow the target
|
||||
// path. If the target is itself a `$ref` location, continue. If the chain
|
||||
// revisits a location, the resulting `*Schema` graph has a cycle that
|
||||
// crashes Validate via stack overflow.
|
||||
//
|
||||
// The chain ends as soon as it reaches a node that is not itself a `$ref`
|
||||
// — that node has its own validation logic (`properties`, `type`, etc.)
|
||||
// which consumes input on each cycle iteration, so the recursion is
|
||||
// bounded. Only pure `$ref` chains form unbounded loops.
|
||||
func (p *preValidator) detectRefCycles() error {
|
||||
for start := range p.refs {
|
||||
visited := map[string]struct{}{}
|
||||
cur := start
|
||||
var chain []string
|
||||
for {
|
||||
if _, ok := visited[cur]; ok {
|
||||
idx := slices.Index(chain, cur)
|
||||
cycle := append(chain[idx:], cur)
|
||||
return fmt.Errorf("identity schema rejected: self-referential $ref cycle: %s",
|
||||
formatRefCycle(cycle))
|
||||
}
|
||||
visited[cur] = struct{}{}
|
||||
chain = append(chain, cur)
|
||||
next, ok := p.refs[cur]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escapeJSONPointer encodes a property name as a JSON-pointer reference
|
||||
// token (RFC 6901): `~` → `~0`, `/` → `~1`. The order matters — `~` must be
|
||||
// escaped first so a literal `/` does not collide with the escape produced
|
||||
// for `~`.
|
||||
func escapeJSONPointer(s string) string {
|
||||
s = strings.ReplaceAll(s, "~", "~0")
|
||||
s = strings.ReplaceAll(s, "/", "~1")
|
||||
return s
|
||||
}
|
||||
|
||||
// formatRefCycle renders a path slice as a human-readable arrow chain,
|
||||
// rendering the empty (root) path as "#".
|
||||
func formatRefCycle(paths []string) string {
|
||||
parts := make([]string, len(paths))
|
||||
for i, p := range paths {
|
||||
if p == "" {
|
||||
parts[i] = "#"
|
||||
} else {
|
||||
parts[i] = "#" + p
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " → ")
|
||||
}
|
||||
178
schema/schema.go
Normal file
178
schema/schema.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/pagination"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
var _ IdentitySchemaList = (*Schemas)(nil)
|
||||
|
||||
type Schemas []Schema
|
||||
|
||||
type IdentitySchemaProvider interface {
|
||||
IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error)
|
||||
}
|
||||
|
||||
type deps interface {
|
||||
config.Provider
|
||||
}
|
||||
|
||||
type DefaultIdentitySchemaProvider struct {
|
||||
d deps
|
||||
}
|
||||
|
||||
func NewDefaultIdentityTraitsProvider(d deps) *DefaultIdentitySchemaProvider {
|
||||
return &DefaultIdentitySchemaProvider{d: d}
|
||||
}
|
||||
|
||||
func (d *DefaultIdentitySchemaProvider) IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error) {
|
||||
ms, err := d.d.Config().IdentityTraitsSchemas(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ss Schemas
|
||||
for _, s := range ms {
|
||||
surl, err := url.Parse(s.URL)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
ss = append(ss, Schema{
|
||||
ID: s.ID,
|
||||
URL: surl,
|
||||
RawURL: s.URL,
|
||||
})
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
type IdentitySchemaList interface {
|
||||
GetByID(id string) (*Schema, error)
|
||||
Total() int
|
||||
List(page, perPage int) Schemas
|
||||
}
|
||||
|
||||
func (s Schemas) GetByID(id string) (*Schema, error) {
|
||||
id = cmp.Or(id, config.DefaultIdentityTraitsSchemaID)
|
||||
|
||||
if ss, ok := s.findSchemaByID(id); ok {
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
if decodedID, ok := TryDecodeID(id); ok {
|
||||
if ss, ok := s.findSchemaByID(decodedID); ok {
|
||||
return ss, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to find JSON Schema ID: %s", id))
|
||||
}
|
||||
|
||||
func (s Schemas) Total() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s Schemas) List(page, perPage int) Schemas {
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 1
|
||||
}
|
||||
start, end := pagination.Index((page+1)*perPage, page*perPage, len(s))
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func (s Schemas) findSchemaByID(id string) (*Schema, bool) {
|
||||
for _, ss := range s {
|
||||
if ss.ID == id {
|
||||
return &ss, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var (
|
||||
orderedKeyCacheMutex sync.RWMutex
|
||||
orderedKeyCache map[string][]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
orderedKeyCache = make(map[string][]string)
|
||||
}
|
||||
|
||||
func computeKeyPositions(schema []byte, dest *[]string, parents []string) {
|
||||
switch gjson.GetBytes(schema, "type").String() {
|
||||
case "object":
|
||||
gjson.GetBytes(schema, "properties").ForEach(func(key, value gjson.Result) bool {
|
||||
computeKeyPositions([]byte(value.Raw), dest, append(parents, strings.ReplaceAll(key.String(), ".", "\\.")))
|
||||
return true
|
||||
})
|
||||
default:
|
||||
*dest = append(*dest, strings.Join(parents, "."))
|
||||
}
|
||||
}
|
||||
|
||||
func GetKeysInOrder(ctx context.Context, schemaRef string) ([]string, error) {
|
||||
orderedKeyCacheMutex.RLock()
|
||||
keysInOrder, ok := orderedKeyCache[schemaRef]
|
||||
orderedKeyCacheMutex.RUnlock()
|
||||
if !ok {
|
||||
sio, err := jsonschema.LoadURL(ctx, schemaRef)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
schema, err := io.ReadAll(io.LimitReader(sio, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
computeKeyPositions(schema, &keysInOrder, []string{})
|
||||
orderedKeyCacheMutex.Lock()
|
||||
orderedKeyCache[schemaRef] = keysInOrder
|
||||
orderedKeyCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
return keysInOrder, nil
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
ID string `json:"id"`
|
||||
URL *url.URL `json:"-"`
|
||||
// RawURL contains the raw URL value as it was passed in the configuration. URL parsing can break base64 encoded URLs.
|
||||
RawURL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *Schema) SchemaURL(host *url.URL) *url.URL {
|
||||
return IDToURL(host, s.ID)
|
||||
}
|
||||
|
||||
func IDToURL(host *url.URL, id string) *url.URL {
|
||||
return urlx.AppendPaths(host, SchemasPath, base64.RawURLEncoding.EncodeToString([]byte(id)))
|
||||
}
|
||||
|
||||
func TryDecodeID(encoded string) (string, bool) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(decoded), true
|
||||
}
|
||||
43
schema/schema_test.go
Normal file
43
schema/schema_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/schema"
|
||||
schematest "github.com/ory/kratos/schema/test"
|
||||
)
|
||||
|
||||
func TestDefaultIdentityTraitsProvider(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
schematest.TestIdentitySchemaProvider(t, schema.NewDefaultIdentityTraitsProvider(reg))
|
||||
}
|
||||
|
||||
func TestGetKeysInOrder(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
schemaRef string
|
||||
keys []string
|
||||
path string
|
||||
}{
|
||||
{schemaRef: "file://./stub/identity.schema.json", keys: []string{"bar", "email"}},
|
||||
{schemaRef: "file://./stub/complex.schema.json", keys: []string{
|
||||
"meal.name", "meal.chef", "traits.email",
|
||||
"traits.stringy", "traits.numby", "traits.booly", "traits.should_big_number", "traits.should_long_string",
|
||||
"fruits", "vegetables",
|
||||
}},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d schemaRef=%s", i, tc.schemaRef), func(t *testing.T) {
|
||||
actual, err := schema.GetKeysInOrder(context.Background(), tc.schemaRef)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.keys, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
87
schema/stub/complex.schema.json
Normal file
87
schema/stub/complex.schema.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"$id": "https://example.com/complex.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["meal"],
|
||||
"properties": {
|
||||
"meal": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"chef": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"booly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"should_big_number": {
|
||||
"type": "number",
|
||||
"minimum": 1200
|
||||
},
|
||||
"should_long_string": {
|
||||
"type": "string",
|
||||
"minLength": 25
|
||||
}
|
||||
}
|
||||
},
|
||||
"fruits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vegetables": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/veggie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"veggie": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"veggieName",
|
||||
"veggieLike"
|
||||
],
|
||||
"properties": {
|
||||
"veggieName": {
|
||||
"type": "string"
|
||||
},
|
||||
"veggieLike": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"veggieAmount": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
schema/stub/extension/invalid.schema.json
Normal file
16
schema/stub/extension/invalid.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
schema/stub/extension/schema.json
Normal file
19
schema/stub/extension/schema.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"totp": {
|
||||
"account_name": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
schema/stub/extension/schema.nested.json
Normal file
22
schema/stub/extension/schema.nested.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"totp": {
|
||||
"account_name": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
schema/stub/identity-2.schema.json
Normal file
11
schema/stub/identity-2.schema.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
schema/stub/identity.schema.json
Normal file
21
schema/stub/identity.schema.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
schema/stub/validator/firstName.schema.json
Normal file
22
schema/stub/validator/firstName.schema.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$id": "https://example.com/firstName.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"description": "The person's first name."
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"description": "The person's last name."
|
||||
},
|
||||
"age": {
|
||||
"description": "Age in years which must be equal to or greater than zero.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
22
schema/stub/validator/whatever.schema.json
Normal file
22
schema/stub/validator/whatever.schema.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$id": "https://example.com/whatever.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"whatever": {
|
||||
"type": "string",
|
||||
"description": "The person's first name."
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"description": "The person's last name."
|
||||
},
|
||||
"age": {
|
||||
"description": "Age in years which must be equal to or greater than zero.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
103
schema/test/schema_provider.go
Normal file
103
schema/test/schema_provider.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestIdentitySchemaProvider(t *testing.T, provider schema.IdentitySchemaProvider) {
|
||||
urlFromID := func(id string) string {
|
||||
return fmt.Sprintf("http://%s.com", id)
|
||||
}
|
||||
|
||||
schemas := schema.Schemas{
|
||||
{ID: "foo"},
|
||||
{ID: "preset://email"},
|
||||
{ID: config.DefaultIdentityTraitsSchemaID},
|
||||
}
|
||||
|
||||
for i := range schemas {
|
||||
raw := urlFromID(schemas[i].ID)
|
||||
schemas[i].RawURL = raw
|
||||
schemas[i].URL = urlx.ParseOrPanic(raw)
|
||||
}
|
||||
|
||||
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
|
||||
config.ViperKeyIdentitySchemas: func() (cs config.Schemas) {
|
||||
for _, s := range schemas {
|
||||
cs = append(cs, config.Schema{
|
||||
ID: s.ID,
|
||||
URL: s.RawURL,
|
||||
})
|
||||
}
|
||||
return cs
|
||||
}(),
|
||||
})
|
||||
|
||||
list, err := provider.IdentityTraitsSchemas(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("GetByID", func(t *testing.T) {
|
||||
t.Run("case=get with raw schemaID", func(t *testing.T) {
|
||||
for _, schema := range schemas {
|
||||
actual, err := list.GetByID(schema.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, schema, *actual)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=get with encoded schemaID", func(t *testing.T) {
|
||||
for _, schema := range schemas {
|
||||
encodedID := base64.RawURLEncoding.EncodeToString([]byte(schema.ID))
|
||||
|
||||
actual, err := list.GetByID(encodedID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, schema, *actual)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=get default schema", func(t *testing.T) {
|
||||
s1, err := list.GetByID("")
|
||||
require.NoError(t, err)
|
||||
s2, err := list.GetByID(config.DefaultIdentityTraitsSchemaID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &schemas[2], s1)
|
||||
assert.Equal(t, &schemas[2], s2)
|
||||
})
|
||||
|
||||
t.Run("case=should return error on not existing id", func(t *testing.T) {
|
||||
s, err := list.GetByID("not existing id")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, (*schema.Schema)(nil), s)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Run("case=get all schemas", func(t *testing.T) {
|
||||
p0 := list.List(0, 4)
|
||||
assert.Equal(t, schemas, p0)
|
||||
})
|
||||
|
||||
t.Run("case=smaller pages", func(t *testing.T) {
|
||||
p0, p1 := list.List(0, 2), list.List(1, 2)
|
||||
assert.Equal(t, schemas, append(p0, p1...))
|
||||
})
|
||||
|
||||
t.Run("case=indexes out of range", func(t *testing.T) {
|
||||
p0 := list.List(-1, 10)
|
||||
assert.Equal(t, schemas, p0)
|
||||
})
|
||||
})
|
||||
}
|
||||
91
schema/validator.go
Normal file
91
schema/validator.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
type ValidationProvider interface {
|
||||
SchemaValidator() *Validator
|
||||
}
|
||||
|
||||
func NewValidator() *Validator {
|
||||
return &Validator{}
|
||||
}
|
||||
|
||||
type validatorOptions struct {
|
||||
e *ExtensionRunner
|
||||
disallowRefs bool
|
||||
}
|
||||
|
||||
func WithExtensionRunner(e *ExtensionRunner) func(*validatorOptions) {
|
||||
return func(o *validatorOptions) {
|
||||
o.e = e
|
||||
}
|
||||
}
|
||||
|
||||
// WithDisallowRefs toggles the restriction on `$ref` URL schemes. When true,
|
||||
// the compiler rejects `$ref` values whose scheme is not `http`, `https`, or
|
||||
// `base64`, preventing local-file reads via `file://`. Callers should forward
|
||||
// the `security.disallow_ref_in_identity_schemas` config value here.
|
||||
func WithDisallowRefs(disallow bool) func(*validatorOptions) {
|
||||
return func(o *validatorOptions) {
|
||||
o.disallowRefs = disallow
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(
|
||||
ctx context.Context,
|
||||
href string,
|
||||
document json.RawMessage,
|
||||
opts ...func(*validatorOptions),
|
||||
) error {
|
||||
var o validatorOptions
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
compiler, err := NewCompilerWithURL(ctx, href, o.disallowRefs)
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Unable to load or parse the JSON schema.").WithWrap(err).WithDebugf("%s", err))
|
||||
}
|
||||
|
||||
if o.e != nil {
|
||||
o.e.Register(compiler)
|
||||
}
|
||||
|
||||
schema, err := compiler.Compile(ctx, href)
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Unable to parse validate JSON object against JSON schema.").WithWrap(err).WithDebugf("%s", err))
|
||||
}
|
||||
|
||||
// we decode explicitly here, so we can handle the error, and it is not lost in the schema validation
|
||||
dec, err := jsonschema.DecodeJSON(bytes.NewBuffer(document))
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse validate JSON object against JSON schema.").WithWrap(err).WithDebugf("%s", err))
|
||||
}
|
||||
if err := schema.ValidateInterface(dec); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if o.e != nil {
|
||||
return o.e.Finish()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
416
schema/validator_test.go
Normal file
416
schema/validator_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/jsonschema/v3/httploader"
|
||||
"github.com/ory/x/httpx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSchemaValidator(t *testing.T) {
|
||||
router := http.NewServeMux()
|
||||
fs := http.StripPrefix("/schema", http.FileServer(http.Dir("stub/validator")))
|
||||
router.HandleFunc("/schema/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
ctx := context.WithValue(ctx, httploader.ContextKey, httpx.NewResilientClient())
|
||||
for k, tc := range []struct {
|
||||
i json.RawMessage
|
||||
err string
|
||||
u string
|
||||
}{
|
||||
{
|
||||
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
},
|
||||
{
|
||||
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": -1 }`),
|
||||
err: "I[#/age] S[#/properties/age/minimum] must be >= 1 but found -1",
|
||||
},
|
||||
{
|
||||
i: json.RawMessage(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
err: `I[#] S[#/additionalProperties] additionalProperties "whatever" not allowed`,
|
||||
},
|
||||
{
|
||||
u: ts.URL + "/schema/whatever.schema.json",
|
||||
i: json.RawMessage(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
},
|
||||
{
|
||||
u: ts.URL + "/schema/whatever.schema.json",
|
||||
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
err: `I[#] S[#/additionalProperties] additionalProperties "firstName" not allowed`,
|
||||
},
|
||||
{
|
||||
u: ts.URL,
|
||||
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
err: "Invalid configuration",
|
||||
},
|
||||
{
|
||||
u: "not-a-url",
|
||||
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
|
||||
err: "Invalid configuration",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
err := NewValidator().Validate(ctx, cmp.Or(tc.u, ts.URL+"/schema/firstName.schema.json"), tc.i)
|
||||
if tc.err == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.EqualError(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaValidator_FileRefExfiltration asserts that identity schemas
|
||||
// cannot exfiltrate server-side files via `$ref: "file://..."`. The jsonschema
|
||||
// `file` loader is registered process-wide so operator-configured top-level
|
||||
// `file://` schemas still work, but per-compiler $ref resolution must reject
|
||||
// the `file` scheme.
|
||||
func TestSchemaValidator_FileRefExfiltration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
secretContents := `"LEAKED_COOKIE_SECRET_VALUE"`
|
||||
|
||||
schemaWithRef := func(refURL string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"$id": "https://test.example.com/probe.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {"identifier": true}
|
||||
}
|
||||
}
|
||||
},
|
||||
"field": {"$ref": %q}
|
||||
},
|
||||
"required": ["email"]
|
||||
}
|
||||
}
|
||||
}`, refURL)
|
||||
}
|
||||
|
||||
serveSchema := func(t *testing.T, body string) string {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/schema", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL + "/schema"
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), httploader.ContextKey, httpx.NewResilientClient())
|
||||
|
||||
t.Run("case=file ref is rejected before any file access", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Fragment that would pass validation if the file were read, and fail
|
||||
// if not. Its contents are a valid JSON-schema fragment.
|
||||
schemaFragment := fmt.Sprintf(`{"type": "string", "const": %s}`, secretContents)
|
||||
fragmentPath := filepath.Join(dir, "fragment.json")
|
||||
require.NoError(t, os.WriteFile(fragmentPath, []byte(schemaFragment), 0o600))
|
||||
|
||||
u := serveSchema(t, schemaWithRef("file://"+fragmentPath))
|
||||
|
||||
t.Run("with disallowRefs=true", func(t *testing.T) {
|
||||
err := NewValidator().Validate(ctx, u,
|
||||
json.RawMessage(`{"traits": {"email": "a@b.c", "field": "wrong"}}`),
|
||||
WithDisallowRefs(true))
|
||||
require.Error(t, err)
|
||||
|
||||
// The file scheme must be rejected, and the file contents must not
|
||||
// leak into the error.
|
||||
var he *herodot.DefaultError
|
||||
require.True(t, stderrors.As(err, &he))
|
||||
assert.Contains(t, he.Debug(), `"file"`, "rejected scheme should appear in cause")
|
||||
assert.NotContains(t, he.Error(), "LEAKED_COOKIE_SECRET_VALUE")
|
||||
assert.NotContains(t, he.Debug(), "LEAKED_COOKIE_SECRET_VALUE")
|
||||
})
|
||||
|
||||
t.Run("with disallowRefs=false preserves legacy exfiltration", func(t *testing.T) {
|
||||
// When the feature flag is off (default for existing deployments),
|
||||
// the legacy behavior must remain: the $ref is dereferenced and
|
||||
// the file contents flow into validation errors. This documents
|
||||
// the intentional opt-in nature of the mitigation.
|
||||
err := NewValidator().Validate(ctx, u,
|
||||
json.RawMessage(`{"traits": {"email": "a@b.c", "field": "wrong"}}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "LEAKED_COOKIE_SECRET_VALUE",
|
||||
"with the flag off, the file contents should still be reachable — this test documents the exploitable default")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestSchemaValidator_RejectsSelfReferentialRef asserts that an identity
|
||||
// schema whose `$ref` resolves back to the same schema (e.g. `{"$ref":"#"}`)
|
||||
// is rejected at compile time.
|
||||
//
|
||||
// Background: github.com/ory/jsonschema/v3 has compile-time cycle detection
|
||||
// (compiler.go:139-202 caches the *Schema in r.schemas[ref] before recursing,
|
||||
// so compilation terminates), but the resulting *Schema has its `Ref` field
|
||||
// pointing at itself. At validate time, schema.go:155-169 unconditionally
|
||||
// invokes s.Ref.validate(v) — for the self-pointer case this recurses
|
||||
// indefinitely until the goroutine stack hits Go's 1GB limit and the runtime
|
||||
// raises `fatal error: stack overflow`. That fatal is **not recoverable**
|
||||
// via recover(); it terminates the entire process. A customer who can
|
||||
// upload an identity schema with a self-pointer can therefore crash any
|
||||
// kratos pod that compiles and validates against it.
|
||||
//
|
||||
// The compile path is the safe place to test this. Calling
|
||||
// NewValidator().Validate would crash the test binary — see the comment in
|
||||
// the body. The only legitimate fix is a kratos-side pre-parse gate that
|
||||
// walks the parsed schema tree, resolves each `$ref`, and rejects refs whose
|
||||
// target is the same schema (or any schema ancestor that produces an
|
||||
// equivalent self-cycle).
|
||||
func TestSchemaValidator_RejectsSelfReferentialRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const schemaJSON = `{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#"}`
|
||||
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(schemaJSON))
|
||||
|
||||
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
|
||||
|
||||
// The kratos-side prevalidate gate (schema/prevalidate.go) rejects the
|
||||
// schema at NewCompilerWithURL time, before the upstream compiler
|
||||
// produces a *Schema with Ref pointing at itself. That is the safest
|
||||
// place to fail: every caller of NewCompilerWithURL is protected, and
|
||||
// no path can construct the self-pointer that crashes Validate.
|
||||
_, err := NewCompilerWithURL(ctx, schemaURL, true)
|
||||
require.Error(t, err,
|
||||
"self-referential $ref ({\"$ref\":\"#\"}) must be rejected at the loader, "+
|
||||
"before producing a *Schema whose Ref field points at itself")
|
||||
assert.Contains(t, err.Error(), "self-referential $ref")
|
||||
|
||||
// End-to-end sanity: Validate must also reject and must not crash the
|
||||
// process via fatal stack overflow. With the gate in place, Validate's
|
||||
// internal call to NewCompilerWithURL fails before reaching the validate
|
||||
// path that would otherwise infinite-recurse on s.Ref == s.
|
||||
err = NewValidator().Validate(ctx, schemaURL,
|
||||
json.RawMessage(`{}`),
|
||||
WithDisallowRefs(true))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestSchemaValidator_PreValidate exercises the kratos-side pre-parse gate
|
||||
// for identity schemas. Each subtest constructs a schema that hits one of
|
||||
// the documented checks in prevalidate.go and asserts that NewValidator()
|
||||
// rejects it before the upstream compiler runs.
|
||||
//
|
||||
// The gate enforces $ref-cycle detection and pattern-regex pre-compilation
|
||||
// only. Structural counters (nesting depth, key count, array arity, total
|
||||
// nodes) are intentionally not enforced here — realistic identity schemas
|
||||
// vary widely and any limit would either be too tight for legitimate
|
||||
// documents or too loose to add value above Go stdlib's JSON depth cap.
|
||||
func TestSchemaValidator_PreValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loadCtx := func(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
return context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
schemaJSON string
|
||||
errFragment string
|
||||
}{
|
||||
{
|
||||
name: "self-referential $ref to root",
|
||||
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#"}`,
|
||||
errFragment: "self-referential $ref",
|
||||
},
|
||||
{
|
||||
name: "self-referential $ref to root, second attempt",
|
||||
schemaJSON: `{
|
||||
"properties": {
|
||||
"traits": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/properties/traits/properties/name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}`,
|
||||
errFragment: "self-referential $ref",
|
||||
},
|
||||
{
|
||||
name: "indirect $ref cycle a → b → a",
|
||||
schemaJSON: `{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$defs": {
|
||||
"a": {"$ref": "#/$defs/b"},
|
||||
"b": {"$ref": "#/$defs/a"}
|
||||
},
|
||||
"$ref": "#/$defs/a"
|
||||
}`,
|
||||
errFragment: "self-referential $ref cycle",
|
||||
},
|
||||
{
|
||||
name: "invalid regex in pattern",
|
||||
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","type":"string","pattern":"(*invalid"}`,
|
||||
errFragment: "invalid regex in pattern",
|
||||
},
|
||||
{
|
||||
name: "invalid regex in patternProperties key",
|
||||
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","patternProperties":{"(*invalid":{"type":"string"}}}`,
|
||||
errFragment: "invalid regex in patternProperties",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(c.schemaJSON))
|
||||
err := NewValidator().Validate(loadCtx(t), schemaURL,
|
||||
json.RawMessage(`{"traits":{"name":"value"}}`),
|
||||
WithDisallowRefs(true))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, fmt.Sprintf("%+v", err), c.errFragment,
|
||||
"expected error to mention %q, got %v", c.errFragment, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaValidator_AcceptsRealisticDeepSchema asserts that a schema with
|
||||
// realistic structural complexity is accepted. Earlier hardening rejected
|
||||
// documents above 32 nesting levels / 1024 keys / 128 array elements; that
|
||||
// proved too tight for legitimate operator schemas. This test pins the
|
||||
// looser policy: the gate must allow these dimensions, and only fail on
|
||||
// the targeted unsafe shapes (cycles, invalid regex).
|
||||
func TestSchemaValidator_AcceptsRealisticDeepSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const depth = 64
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"traits":`)
|
||||
for range depth {
|
||||
sb.WriteString(`{"type":"object","properties":{"x":`)
|
||||
}
|
||||
sb.WriteString(`{"type":"string"}`)
|
||||
for range depth {
|
||||
sb.WriteString(`}}`)
|
||||
}
|
||||
sb.WriteString(`}}`)
|
||||
|
||||
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(sb.String()))
|
||||
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
|
||||
|
||||
_, err := NewCompilerWithURL(ctx, schemaURL, true)
|
||||
require.NoError(t, err,
|
||||
"schema with %d nesting levels must be accepted; the prevalidate gate "+
|
||||
"intentionally does not enforce a depth cap", depth)
|
||||
}
|
||||
|
||||
// TestSchemaValidator_BodyTooLarge asserts that a schema URL whose response
|
||||
// body exceeds MaxSchemaBodyBytes is rejected before any parse or compile
|
||||
// runs. This guards against memory exhaustion when a customer-controlled
|
||||
// URL serves a multi-megabyte (or larger) body.
|
||||
func TestSchemaValidator_BodyTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := bytes.Repeat([]byte("X"), MaxSchemaBodyBytes+1024)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/big.schema.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// httptest binds 127.0.0.1 — that is a private IP for the SSRF guard.
|
||||
// Use a non-guarded resilient client so we exercise the size cap, not
|
||||
// the SSRF guard.
|
||||
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
|
||||
|
||||
err := NewValidator().Validate(ctx, ts.URL+"/big.schema.json",
|
||||
json.RawMessage(`{}`),
|
||||
WithDisallowRefs(true))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, fmt.Sprintf("%+v", err), "body exceeds")
|
||||
}
|
||||
|
||||
// TestLoadRefURL pins the actual scheme allowlist enforced by loadRefURL.
|
||||
// The implementation only accepts base64. Any future relaxation must
|
||||
// update this test.
|
||||
func TestLoadRefURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantSub string
|
||||
}{
|
||||
{"file scheme rejected", "file:///etc/passwd", `scheme "file"`},
|
||||
{"http scheme rejected", "http://example.com/schema.json", `scheme "http"`},
|
||||
{"https scheme rejected", "https://example.com/schema.json", `scheme "https"`},
|
||||
{"data scheme rejected", "data:application/json,{}", `scheme "data"`},
|
||||
{"empty scheme rejected", "/relative/path", `scheme ""`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := loadRefURL(t.Context(), c.raw)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.wantSub)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureGuardedHTTPClient_PrivateIPRejected verifies that a context
|
||||
// without an attached HTTP client gets a default SSRF-guarded one when
|
||||
// passed to NewCompilerWithURL. A schema URL pointing at httptest's
|
||||
// loopback listener (127.0.0.1) must be rejected by the dialer before any
|
||||
// HTTP request hits the test server.
|
||||
func TestEnsureGuardedHTTPClient_PrivateIPRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var hit atomic.Bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/schema.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
hit.Store(true)
|
||||
_, _ = w.Write([]byte(`{"type":"object"}`))
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Bare context — no httploader.ContextKey attached. NewCompilerWithURL
|
||||
// must attach a guarded default client and refuse the loopback dial.
|
||||
_, err := NewCompilerWithURL(t.Context(), ts.URL+"/schema.json", true)
|
||||
require.Error(t, err, "fetch via SSRF-guarded default client must reject loopback addresses")
|
||||
assert.False(t, hit.Load(),
|
||||
"server handler was invoked — SSRF guard did not block the request")
|
||||
}
|
||||
Reference in New Issue
Block a user