Skip to content

Commit aaac2ec

Browse files
committed
fix: tailor validation errors and requiredness to schema
The implementation up to now unblocks using SMS verification, but it had the downside of always adding phone fields to verification unconditionally. (The previous implementation added the email field unconditionally.) This commit makes the verification fields conditional on the schema.
1 parent a021590 commit aaac2ec

7 files changed

+405
-25
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
[
2+
{
3+
"attributes": {
4+
"disabled": false,
5+
"name": "email",
6+
"node_type": "input",
7+
"type": "email"
8+
},
9+
"group": "code",
10+
"messages": [],
11+
"meta": {
12+
"label": {
13+
"id": 1070007,
14+
"text": "Email",
15+
"type": "info"
16+
}
17+
},
18+
"type": "input"
19+
},
20+
{
21+
"attributes": {
22+
"disabled": false,
23+
"name": "phone",
24+
"node_type": "input",
25+
"type": "tel"
26+
},
27+
"group": "code",
28+
"messages": [],
29+
"meta": {
30+
"label": {
31+
"id": 1070016,
32+
"text": "Phone",
33+
"type": "info"
34+
}
35+
},
36+
"type": "input"
37+
},
38+
{
39+
"attributes": {
40+
"disabled": false,
41+
"name": "method",
42+
"node_type": "input",
43+
"type": "submit",
44+
"value": "code"
45+
},
46+
"group": "code",
47+
"messages": [],
48+
"meta": {
49+
"label": {
50+
"id": 1070009,
51+
"text": "Continue",
52+
"type": "info"
53+
}
54+
},
55+
"type": "input"
56+
},
57+
{
58+
"attributes": {
59+
"disabled": false,
60+
"name": "csrf_token",
61+
"node_type": "input",
62+
"required": true,
63+
"type": "hidden"
64+
},
65+
"group": "default",
66+
"messages": [],
67+
"meta": {},
68+
"type": "input"
69+
}
70+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
[
2+
{
3+
"attributes": {
4+
"disabled": false,
5+
"name": "phone",
6+
"node_type": "input",
7+
"required": true,
8+
"type": "tel"
9+
},
10+
"group": "code",
11+
"messages": [],
12+
"meta": {
13+
"label": {
14+
"id": 1070016,
15+
"text": "Phone",
16+
"type": "info"
17+
}
18+
},
19+
"type": "input"
20+
},
21+
{
22+
"attributes": {
23+
"disabled": false,
24+
"name": "method",
25+
"node_type": "input",
26+
"type": "submit",
27+
"value": "code"
28+
},
29+
"group": "code",
30+
"messages": [],
31+
"meta": {
32+
"label": {
33+
"id": 1070009,
34+
"text": "Continue",
35+
"type": "info"
36+
}
37+
},
38+
"type": "input"
39+
},
40+
{
41+
"attributes": {
42+
"disabled": false,
43+
"name": "csrf_token",
44+
"node_type": "input",
45+
"required": true,
46+
"type": "hidden"
47+
},
48+
"group": "default",
49+
"messages": [],
50+
"meta": {},
51+
"type": "input"
52+
}
53+
]

selfservice/strategy/code/strategy.go

+133-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package code
66
import (
77
"context"
88
"encoding/json"
9+
"fmt"
10+
"io"
911
"net/http"
1012
"sort"
1113
"strings"
@@ -16,6 +18,7 @@ import (
1618
"github.com/tidwall/gjson"
1719

1820
"github.com/ory/herodot"
21+
"github.com/ory/jsonschema/v3"
1922
"github.com/ory/kratos/continuity"
2023
"github.com/ory/kratos/courier"
2124
"github.com/ory/kratos/driver/config"
@@ -247,6 +250,132 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error {
247250
return nil
248251
}
249252

253+
func (s *Strategy) GetSupportedVerificationChannels(ctx context.Context) (map[identity.VerifiableAddressType]bool, error) {
254+
channels := make(map[identity.VerifiableAddressType]bool)
255+
256+
schemaList, err := s.deps.IdentityTraitsSchemas(ctx)
257+
if err != nil {
258+
return nil, err
259+
}
260+
261+
schemaID := s.deps.Config().DefaultIdentityTraitsSchemaID(ctx)
262+
identitySchema, err := schemaList.GetByID(schemaID)
263+
if err != nil {
264+
return nil, err
265+
}
266+
267+
rawURL := identitySchema.RawURL
268+
if rawURL == "" && identitySchema.URL != nil {
269+
rawURL = identitySchema.URL.String()
270+
}
271+
272+
schemaFile, err := jsonschema.LoadURL(ctx, rawURL)
273+
if err != nil {
274+
return nil, err
275+
}
276+
277+
schemaData, err := io.ReadAll(io.LimitReader(schemaFile, 1024*1024))
278+
if err != nil {
279+
return nil, err
280+
}
281+
282+
var schemaJSON map[string]interface{}
283+
if err := json.Unmarshal(schemaData, &schemaJSON); err != nil {
284+
return nil, err
285+
}
286+
287+
// properties.traits.properties.<trait>."ory.sh/kratos".verification.via
288+
if props, ok := schemaJSON["properties"].(map[string]interface{}); ok {
289+
if traits, ok := props["traits"].(map[string]interface{}); ok {
290+
if traitProps, ok := traits["properties"].(map[string]interface{}); ok {
291+
for _, propValue := range traitProps {
292+
if prop, ok := propValue.(map[string]interface{}); ok {
293+
if ext, ok := prop[schema.ExtensionName].(map[string]interface{}); ok {
294+
if verification, ok := ext["verification"].(map[string]interface{}); ok {
295+
// Set the appropriate channel based on verification via value
296+
switch verification["via"].(string) {
297+
case identity.ChannelTypeEmail:
298+
channels[identity.VerifiableAddressTypeEmail] = true
299+
case identity.ChannelTypeSMS:
300+
channels[identity.VerifiableAddressTypePhone] = true
301+
}
302+
}
303+
}
304+
}
305+
}
306+
}
307+
}
308+
}
309+
310+
if len(channels) == 0 {
311+
return nil, fmt.Errorf("no verification channels found in schema")
312+
}
313+
314+
return channels, nil
315+
}
316+
317+
func (s *Strategy) GetVerificationRequiredField(ctx context.Context) (pointer string, property string, err error) {
318+
channels, err := s.GetSupportedVerificationChannels(ctx)
319+
if err != nil {
320+
return "#/identifier", "identifier", err
321+
}
322+
323+
activeChannelCount := 0
324+
for _, active := range channels {
325+
if active {
326+
activeChannelCount++
327+
}
328+
}
329+
330+
// Return field-specific errors if only one channel is supported
331+
if activeChannelCount == 1 {
332+
if channels[identity.VerifiableAddressTypeEmail] {
333+
return "#/email", "email", nil
334+
}
335+
if channels[identity.VerifiableAddressTypePhone] {
336+
return "#/phone", "phone", nil
337+
}
338+
}
339+
340+
// Default to generic identifier error
341+
return "#/identifier", "identifier", nil
342+
}
343+
344+
func (s *Strategy) addVerificationNodes(ctx context.Context, nodes *node.Nodes, email interface{}, phone interface{}) error {
345+
channels, err := s.GetSupportedVerificationChannels(ctx)
346+
if err != nil {
347+
return err
348+
}
349+
350+
activeChannelCount := 0
351+
for _, active := range channels {
352+
if active {
353+
activeChannelCount++
354+
}
355+
}
356+
357+
opts := []node.InputAttributesModifier{}
358+
if activeChannelCount == 1 {
359+
opts = append(opts, node.WithRequiredInputAttribute)
360+
}
361+
362+
if channels[identity.VerifiableAddressTypeEmail] {
363+
nodes.Append(
364+
node.NewInputField("email", email, node.CodeGroup, node.InputAttributeTypeEmail, opts...).
365+
WithMetaLabel(text.NewInfoNodeInputEmail()),
366+
)
367+
}
368+
369+
if channels[identity.VerifiableAddressTypePhone] {
370+
nodes.Append(
371+
node.NewInputField("phone", phone, node.CodeGroup, node.InputAttributeTypeTel, opts...).
372+
WithMetaLabel(text.NewInfoNodeInputPhone()),
373+
)
374+
}
375+
376+
return nil
377+
}
378+
250379
func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error {
251380
ctx := r.Context()
252381
switch f := f.(type) {
@@ -260,15 +389,10 @@ func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error
260389
WithMetaLabel(text.NewInfoNodeLabelContinue()),
261390
)
262391
case *verification.Flow:
263-
// Add both email and phone fields for verification
264-
f.GetUI().Nodes.Append(
265-
node.NewInputField("email", nil, node.CodeGroup, node.InputAttributeTypeEmail).
266-
WithMetaLabel(text.NewInfoNodeInputEmail()),
267-
)
268-
f.GetUI().Nodes.Append(
269-
node.NewInputField("phone", nil, node.CodeGroup, node.InputAttributeTypeTel).
270-
WithMetaLabel(text.NewInfoNodeInputPhone()),
271-
)
392+
if err := s.addVerificationNodes(ctx, &f.GetUI().Nodes, nil, nil); err != nil {
393+
return err
394+
}
395+
272396
f.GetUI().Nodes.Append(
273397
node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).
274398
WithMetaLabel(text.NewInfoNodeLabelContinue()),

selfservice/strategy/code/strategy_verification.go

+28-16
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ func (s *Strategy) decodeVerification(r *http.Request) (*updateVerificationFlowW
6565
return &body, nil
6666
}
6767

68-
// handleVerificationError is a convenience function for handling all types of errors that may occur (e.g. validation error).
6968
func (s *Strategy) handleVerificationError(r *http.Request, f *verification.Flow, body *updateVerificationFlowWithCodeMethod, err error) error {
7069
if f != nil {
7170
f.UI.SetCSRF(s.deps.GenerateCSRFToken(r))
@@ -76,12 +75,11 @@ func (s *Strategy) handleVerificationError(r *http.Request, f *verification.Flow
7675
phone = body.Phone
7776
}
7877

79-
f.UI.GetNodes().Upsert(
80-
node.NewInputField("email", email, node.CodeGroup, node.InputAttributeTypeEmail).WithMetaLabel(text.NewInfoNodeInputEmail()),
81-
)
82-
f.UI.GetNodes().Upsert(
83-
node.NewInputField("phone", phone, node.CodeGroup, node.InputAttributeTypeTel).WithMetaLabel(text.NewInfoNodeInputPhone()),
84-
)
78+
f.UI.Nodes = node.Nodes{}
79+
80+
if updateErr := s.addVerificationNodes(r.Context(), &f.UI.Nodes, email, phone); updateErr != nil {
81+
return errors.Wrap(err, updateErr.Error())
82+
}
8583
}
8684

8785
return err
@@ -217,9 +215,25 @@ func (s *Strategy) verificationHandleFormSubmission(ctx context.Context, w http.
217215

218216
// If not GET: try to use the submitted code
219217
return s.verificationUseCode(ctx, w, r, body.Code, f)
220-
} else if len(body.Email) == 0 && len(body.Phone) == 0 {
221-
// If no code, email, or phone was provided, fail with a validation error
222-
return s.handleVerificationError(r, f, body, schema.NewRequiredError("#/identifier", "identifier"))
218+
}
219+
220+
channels, err := s.GetSupportedVerificationChannels(ctx)
221+
if err != nil {
222+
return s.handleVerificationError(r, f, body, err)
223+
}
224+
225+
hasValidIdentifier := false
226+
if channels[identity.VerifiableAddressTypeEmail] && len(body.Email) > 0 {
227+
hasValidIdentifier = true
228+
}
229+
if channels[identity.VerifiableAddressTypePhone] && len(body.Phone) > 0 {
230+
hasValidIdentifier = true
231+
}
232+
233+
if !hasValidIdentifier {
234+
// If no code and no valid identifier was provided, fail with a validation error
235+
pointer, property, _ := s.GetVerificationRequiredField(ctx)
236+
return s.handleVerificationError(r, f, body, schema.NewRequiredError(pointer, property))
223237
}
224238

225239
if err := flow.EnsureCSRF(s.deps, r, f.Type, s.deps.Config().DisableAPIFlowEnforcement(ctx), s.deps.GenerateCSRFToken, body.CSRFToken); err != nil {
@@ -230,16 +244,15 @@ func (s *Strategy) verificationHandleFormSubmission(ctx context.Context, w http.
230244
return s.handleVerificationError(r, f, body, err)
231245
}
232246

233-
// Handle verification via email or SMS based on provided identifier
234-
if len(body.Email) > 0 {
247+
if channels[identity.VerifiableAddressTypeEmail] && len(body.Email) > 0 {
235248
if err := s.deps.CodeSender().SendVerificationCode(ctx, f, identity.VerifiableAddressTypeEmail, body.Email); err != nil {
236249
if !errors.Is(err, ErrUnknownAddress) {
237250
return s.handleVerificationError(r, f, body, err)
238251
}
239252
// Continue execution
240253
}
241254
f.State = flow.StateEmailSent
242-
} else if len(body.Phone) > 0 {
255+
} else if channels[identity.VerifiableAddressTypePhone] && len(body.Phone) > 0 {
243256
if err := s.deps.CodeSender().SendVerificationCode(ctx, f, identity.VerifiableAddressTypePhone, body.Phone); err != nil {
244257
if !errors.Is(err, ErrUnknownAddress) {
245258
return s.handleVerificationError(r, f, body, err)
@@ -253,15 +266,14 @@ func (s *Strategy) verificationHandleFormSubmission(ctx context.Context, w http.
253266
return s.handleVerificationError(r, f, body, err)
254267
}
255268

256-
// Add appropriate resend buttons based on the identifier used
257-
if body.Email != "" {
269+
if channels[identity.VerifiableAddressTypeEmail] && body.Email != "" {
258270
f.UI.Nodes.Append(
259271
node.NewInputField("email", body.Email, node.CodeGroup, node.InputAttributeTypeSubmit).
260272
WithMetaLabel(text.NewInfoNodeResendOTP()),
261273
)
262274
}
263275

264-
if body.Phone != "" {
276+
if channels[identity.VerifiableAddressTypePhone] && body.Phone != "" {
265277
f.UI.Nodes.Append(
266278
node.NewInputField("phone", body.Phone, node.CodeGroup, node.InputAttributeTypeSubmit).
267279
WithMetaLabel(text.NewInfoNodeResendOTP()),

0 commit comments

Comments
 (0)