Skip to content

Commit e48e9fa

Browse files
authored
feat: implement blocking webhooks (#1585)
feat: implement blocking webhooks (#1585) This feature allows webhooks to return validation errors in the registration and login flow from a webhook. This feature enables you to deny sign-ups from a specific domain, for example. A big thank you goes out to the team at Wikia / Fandom for implementing and contributing to this feature! Closes #1724 Closes #1483
1 parent 8b791b9 commit e48e9fa

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1333
-68
lines changed

embedx/config.schema.json

+35
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@
208208
}
209209
]
210210
},
211+
"can_interrupt": {
212+
"type": "boolean",
213+
"default": false,
214+
"description": "If enabled allows the web hook to interrupt / abort the self-service flow. It only applies to certain flows (registration/verification/login/settings) and requires a valid response format."
215+
},
211216
"auth": {
212217
"type": "object",
213218
"title": "Auth mechanisms",
@@ -223,6 +228,36 @@
223228
},
224229
"additionalProperties": false
225230
},
231+
"anyOf": [
232+
{
233+
"not":
234+
{
235+
"properties": {
236+
"response": {
237+
"properties": {
238+
"ignore": {
239+
"enum": [
240+
true
241+
]
242+
}
243+
},
244+
"required": ["ignore"]
245+
}
246+
},
247+
"required": ["response"]
248+
}
249+
},
250+
{
251+
"properties": {
252+
"can_interrupt": {
253+
"enum": [
254+
false
255+
]
256+
}
257+
},
258+
"require": ["can_interrupt"]
259+
}
260+
],
226261
"additionalProperties": false,
227262
"required": [
228263
"url",

identity/credentials.go

+18
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/ory/kratos/corp"
9+
"github.com/ory/kratos/ui/node"
910

1011
"github.com/gofrs/uuid"
1112

@@ -42,6 +43,23 @@ func (c CredentialsType) String() string {
4243
return string(c)
4344
}
4445

46+
func (c CredentialsType) ToUiNodeGroup() node.UiNodeGroup {
47+
switch c {
48+
case CredentialsTypePassword:
49+
return node.PasswordGroup
50+
case CredentialsTypeOIDC:
51+
return node.OpenIDConnectGroup
52+
case CredentialsTypeTOTP:
53+
return node.TOTPGroup
54+
case CredentialsTypeWebAuthn:
55+
return node.WebAuthnGroup
56+
case CredentialsTypeLookup:
57+
return node.LookupGroup
58+
default:
59+
return node.DefaultGroup
60+
}
61+
}
62+
4563
// Please make sure to add all of these values to the test that ensures they are created during migration
4664
const (
4765
CredentialsTypePassword CredentialsType = "password"

schema/errors.go

+44
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,50 @@ func NewNoWebAuthnRegistered() error {
247247
})
248248
}
249249

250+
func NewHookValidationError(instancePtr, message string, messages text.Messages) *ValidationError {
251+
return &ValidationError{
252+
ValidationError: &jsonschema.ValidationError{
253+
Message: message,
254+
InstancePtr: instancePtr,
255+
},
256+
Messages: messages,
257+
}
258+
}
259+
260+
type ValidationListError struct {
261+
Validations []*ValidationError
262+
}
263+
264+
func (e ValidationListError) Error() string {
265+
var detailError string
266+
for pos, validationErr := range e.Validations {
267+
detailError = detailError + fmt.Sprintf("\n(%d) %s", pos, validationErr.Error())
268+
}
269+
return fmt.Sprintf("%d validation errors occurred:%s", len(e.Validations), detailError)
270+
}
271+
272+
func (e *ValidationListError) Add(v *ValidationError) {
273+
e.Validations = append(e.Validations, v)
274+
}
275+
276+
func (e ValidationListError) HasErrors() bool {
277+
return len(e.Validations) > 0
278+
}
279+
280+
func (e *ValidationListError) WithError(instancePtr, message string, details text.Messages) {
281+
e.Validations = append(e.Validations, &ValidationError{
282+
ValidationError: &jsonschema.ValidationError{
283+
Message: message,
284+
InstancePtr: instancePtr,
285+
},
286+
Messages: details,
287+
})
288+
}
289+
290+
func NewValidationListError(errs []*ValidationError) error {
291+
return errors.WithStack(&ValidationListError{Validations: errs})
292+
}
293+
250294
func NewNoWebAuthnCredentials() error {
251295
return errors.WithStack(&ValidationError{
252296
ValidationError: &jsonschema.ValidationError{

schema/errors_test.go

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package schema
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/ory/jsonschema/v3"
9+
10+
"github.com/ory/kratos/text"
11+
)
12+
13+
func TestListValidationErrors(t *testing.T) {
14+
testErr := ValidationListError{}
15+
16+
assert.False(t, testErr.HasErrors())
17+
18+
testErr.WithError("#/traits/password", "error message", new(text.Messages).Add(text.NewErrorValidationDuplicateCredentials()))
19+
assert.True(t, testErr.HasErrors())
20+
assert.Len(t, testErr.Validations, 1)
21+
22+
validationError := &ValidationError{
23+
ValidationError: &jsonschema.ValidationError{
24+
Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`,
25+
InstancePtr: "#/",
26+
Context: &ValidationErrorContextPasswordPolicyViolation{},
27+
},
28+
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()),
29+
}
30+
testErr.Add(validationError)
31+
assert.Len(t, testErr.Validations, 2)
32+
assert.Equal(t, "2 validation errors occurred:"+
33+
"\n(0) I[#/traits/password] S[] error message"+
34+
"\n(1) I[#/] S[] the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number",
35+
testErr.Error())
36+
}

selfservice/flow/error.go

+26
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"time"
88

99
"github.com/ory/kratos/driver/config"
10+
"github.com/ory/kratos/identity"
11+
"github.com/ory/kratos/ui/container"
12+
"github.com/ory/kratos/ui/node"
1013
"github.com/ory/kratos/x"
1114
"github.com/ory/x/urlx"
1215

@@ -90,6 +93,29 @@ func NewBrowserLocationChangeRequiredError(redirectTo string) *BrowserLocationCh
9093
}
9194
}
9295

96+
func HandleHookError(_ http.ResponseWriter, r *http.Request, f Flow, traits identity.Traits, group node.UiNodeGroup, flowError error, logger x.LoggingProvider, csrf x.CSRFTokenGeneratorProvider) error {
97+
if f != nil {
98+
if traits != nil {
99+
cont, err := container.NewFromStruct("", group, traits, "traits")
100+
if err != nil {
101+
logger.Logger().WithError(err).Error("could not update flow UI")
102+
return err
103+
}
104+
105+
for _, n := range cont.Nodes {
106+
// we only set the value and not the whole field because we want to keep types from the initial form generation
107+
f.GetUI().Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
108+
}
109+
}
110+
111+
if f.GetType() == TypeBrowser {
112+
f.GetUI().SetCSRF(csrf.GenerateCSRFToken(r))
113+
}
114+
}
115+
116+
return flowError
117+
}
118+
93119
func GetFlowExpiredRedirectURL(config *config.Config, route, returnTo string) *url.URL {
94120
redirectURL := urlx.AppendPaths(config.SelfPublicURL(), route)
95121
if returnTo != "" {

selfservice/flow/error_test.go

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package flow
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/url"
7+
"testing"
8+
9+
"github.com/gofrs/uuid"
10+
"github.com/sirupsen/logrus"
11+
"github.com/stretchr/testify/assert"
12+
13+
"github.com/ory/kratos/identity"
14+
"github.com/ory/kratos/schema"
15+
"github.com/ory/kratos/text"
16+
"github.com/ory/kratos/ui/container"
17+
"github.com/ory/kratos/ui/node"
18+
"github.com/ory/kratos/x"
19+
"github.com/ory/x/httpx"
20+
"github.com/ory/x/logrusx"
21+
"github.com/ory/x/otelx"
22+
)
23+
24+
type testCSRFTokenGenerator struct{}
25+
26+
func (t *testCSRFTokenGenerator) GenerateCSRFToken(_ *http.Request) string {
27+
return "csrf_token_value"
28+
}
29+
30+
// testFlow is a minimalistic flow implementation to satisfy interface and is used only in tests.
31+
type testFlow struct {
32+
// ID represents the flow's unique ID.
33+
//
34+
// required: true
35+
ID uuid.UUID `json:"id" faker:"-" db:"id" rw:"r"`
36+
37+
// Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction.
38+
//
39+
// required: true
40+
Type Type `json:"type" db:"type" faker:"flow_type"`
41+
42+
// RequestURL is the initial URL that was requested from Ory Kratos. It can be used
43+
// to forward information contained in the URL's path or query for example.
44+
//
45+
// required: true
46+
RequestURL string `json:"request_url" db:"request_url"`
47+
48+
// UI contains data which must be shown in the user interface.
49+
//
50+
// required: true
51+
UI *container.Container `json:"ui" db:"ui"`
52+
}
53+
54+
func (t *testFlow) GetID() uuid.UUID {
55+
return t.ID
56+
}
57+
58+
func (t *testFlow) GetType() Type {
59+
return t.Type
60+
}
61+
62+
func (t *testFlow) GetRequestURL() string {
63+
return t.RequestURL
64+
}
65+
66+
func (t *testFlow) AppendTo(url *url.URL) *url.URL {
67+
return AppendFlowTo(url, t.ID)
68+
}
69+
70+
func (t *testFlow) GetUI() *container.Container {
71+
return t.UI
72+
}
73+
74+
func newTestFlow(r *http.Request, flowType Type) Flow {
75+
id := x.NewUUID()
76+
requestURL := x.RequestURL(r).String()
77+
ui := &container.Container{
78+
Method: "POST",
79+
Action: "/test",
80+
}
81+
82+
ui.Nodes.Append(node.NewInputField("traits.username", nil, node.PasswordGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute))
83+
ui.Nodes.Append(node.NewInputField("traits.password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute))
84+
85+
return &testFlow{
86+
ID: id,
87+
UI: ui,
88+
RequestURL: requestURL,
89+
Type: flowType,
90+
}
91+
}
92+
93+
func prepareTraits(username, password string) identity.Traits {
94+
payload := struct {
95+
Username string `json:"username"`
96+
Password string `json:"password"`
97+
}{username, password}
98+
99+
data, _ := json.Marshal(payload)
100+
return data
101+
}
102+
103+
func TestHandleHookError(t *testing.T) {
104+
r := &http.Request{URL: &url.URL{RawQuery: ""}}
105+
logger := logrusx.New("kratos", "test", logrusx.ForceLevel(logrus.FatalLevel))
106+
l := &x.SimpleLoggerWithClient{L: logger, C: httpx.NewResilientClient(), T: otelx.NewNoop(logger, &otelx.Config{ServiceName: "kratos"})}
107+
csrf := testCSRFTokenGenerator{}
108+
f := newTestFlow(r, TypeBrowser)
109+
tr := prepareTraits("foo", "bar")
110+
111+
t.Run("case=fill_in_traits", func(t *testing.T) {
112+
ve := schema.NewValidationListError([]*schema.ValidationError{schema.NewHookValidationError("traits.username", "invalid username", text.Messages{})})
113+
114+
err := HandleHookError(nil, r, f, tr, node.PasswordGroup, ve, l, &csrf)
115+
assert.ErrorIs(t, err, ve)
116+
if assert.NotEmpty(t, f.GetUI()) {
117+
ui := f.GetUI()
118+
assert.Len(t, ui.Nodes, 3)
119+
assert.ElementsMatch(t, ui.Nodes,
120+
node.Nodes{
121+
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "traits.username", Type: node.InputAttributeTypeText, FieldValue: "foo", Required: true}, Meta: &node.Meta{}},
122+
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "traits.password", Type: node.InputAttributeTypePassword, FieldValue: "bar", Required: true}, Meta: &node.Meta{}},
123+
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "csrf_token", Type: node.InputAttributeTypeHidden, FieldValue: "csrf_token_value", Required: true}},
124+
})
125+
}
126+
})
127+
128+
t.Run("case=unmarshal_fail", func(t *testing.T) {
129+
ve := schema.NewValidationListError([]*schema.ValidationError{schema.NewHookValidationError("traits.username", "invalid username", text.Messages{})})
130+
131+
err := HandleHookError(nil, r, f, []byte("garbage"), node.PasswordGroup, ve, l, &csrf)
132+
var jsonErr *json.SyntaxError
133+
assert.ErrorAs(t, err, &jsonErr)
134+
})
135+
}

selfservice/flow/flow.go

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66

77
"github.com/pkg/errors"
88

9+
"github.com/ory/kratos/ui/container"
10+
911
"github.com/ory/herodot"
1012
"github.com/ory/kratos/x"
1113

@@ -31,4 +33,5 @@ type Flow interface {
3133
GetType() Type
3234
GetRequestURL() string
3335
AppendTo(*url.URL) *url.URL
36+
GetUI() *container.Container
3437
}

selfservice/flow/login/flow.go

+4
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,7 @@ func (f *Flow) AfterSave(*pop.Connection) error {
210210
f.SetReturnTo()
211211
return nil
212212
}
213+
214+
func (f *Flow) GetUI() *container.Container {
215+
return f.UI
216+
}

0 commit comments

Comments
 (0)