Skip to content

Commit

Permalink
feat: add ids for user-facing errors for login, registration, settings
Browse files Browse the repository at this point in the history
This patch adds a new field `id` to JSON error payloads. This helps tremendously in implementing better client-side (native / SPA) apps as the API now returns error IDs like `no_active_session`, `orbidden_return_to`, `no_verified_address` and more. UIs can use these IDs to decide what to do next in the application - for example redirecting to a particular endpoint or showing an error message.
  • Loading branch information
aeneasr committed Oct 19, 2021
1 parent be99f8e commit 787558b
Show file tree
Hide file tree
Showing 15 changed files with 227 additions and 135 deletions.
42 changes: 42 additions & 0 deletions selfservice/flow/error.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
package flow

import (
"github.com/ory/kratos/x"
"time"

"github.com/gofrs/uuid"
"github.com/pkg/errors"

"github.com/ory/herodot"
"github.com/ory/kratos/text"
)

var (
ErrStrategyNotResponsible = errors.New("strategy is not responsible for this request")
ErrCompletedByStrategy = errors.New("flow response completed by strategy")
ErrStrategyAsksToReturnToUI = errors.New("flow strategy is redirecting to the ui")
)

// Is sent when a flow is expired
//
// swagger:model selfServiceFlowExpiredError
type ExpiredError struct {
*herodot.DefaultError

// Since when the flow has expired
Ago time.Duration `json:"since"`

// The flow ID that should be used for the new flow as it contains the correct messages.
FlowID uuid.UUID `json:"use_flow_id"`

flow Flow
}

func (e *ExpiredError) WithFlow(flow Flow) *ExpiredError {
e.FlowID = flow.GetID()
e.flow = flow
return e
}

func (e *ExpiredError) GetFlow() Flow {
return e.flow
}

func NewFlowExpiredError(at time.Time) *ExpiredError {
ago := time.Since(at)
return &ExpiredError{
Ago: ago,
DefaultError: x.ErrGone.WithID(text.ErrIDSelfServiceFlowExpired).
WithError("self-service flow expired").
WithReasonf("The self-service flow expired %.2f minutes ago, initialize a new one.", ago.Minutes()),
}
}
1 change: 1 addition & 0 deletions selfservice/flow/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ type Flow interface {
GetID() uuid.UUID
GetType() Type
GetRequestURL() string
AppendTo(*url.URL) *url.URL
}
69 changes: 28 additions & 41 deletions selfservice/flow/login/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@ package login

import (
"net/http"
"net/url"
"time"

"github.com/ory/kratos/ui/node"

"github.com/ory/x/urlx"

"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"

Expand All @@ -23,14 +19,14 @@ import (

var (
ErrHookAbortFlow = errors.New("aborted login hook execution")
ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithError("you are already logged in").WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
ErrAddressNotVerified = herodot.ErrBadRequest.WithError("your email or phone address is not yet verified").WithReason("Your account's email or phone address are not verified yet. Please check your email or phone inbox or re-request verification.")
ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithID(text.ErrIDAlreadyLoggedIn).WithError("you are already logged in").WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
ErrAddressNotVerified = herodot.ErrBadRequest.WithID(text.ErrIDAddressNotVerified).WithError("your email or phone address is not yet verified").WithReason("Your account's email or phone address are not verified yet. Please check your email or phone inbox or re-request verification.")

// ErrSessionHasAALAlready is returned when one attempts to upgrade the AAL of an active session which already has that AAL.
ErrSessionHasAALAlready = herodot.ErrUnauthorized.WithError("session has the requested authenticator assurance level already").WithReason("The session has the requested AAL already.")
ErrSessionHasAALAlready = herodot.ErrUnauthorized.WithID(text.ErrIDSessionHasAALAlready).WithError("session has the requested authenticator assurance level already").WithReason("The session has the requested AAL already.")

// ErrSessionRequiredForHigherAAL is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
ErrSessionRequiredForHigherAAL = herodot.ErrUnauthorized.WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
ErrSessionRequiredForHigherAAL = herodot.ErrUnauthorized.WithID(text.ErrIDSessionRequiredForHigherAAL).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
)

type (
Expand All @@ -49,26 +45,29 @@ type (
ErrorHandler struct {
d errorHandlerDependencies
}
)

FlowExpiredError struct {
*herodot.DefaultError
ago time.Duration
func NewFlowErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}

func (s *ErrorHandler) PrepareReplacementForExpiredFlow(w http.ResponseWriter, r *http.Request, f *Flow, err error) (*flow.ExpiredError, error) {
e := new(flow.ExpiredError)
if !errors.As(err, &e) {
return nil, nil
}
// create new flow because the old one is not valid
a, err := s.d.LoginHandler().FromOldFlow(w, r, *f)
if err != nil {
return nil, err
}
)

func NewFlowExpiredError(at time.Time) *FlowExpiredError {
ago := time.Since(at)
return &FlowExpiredError{
ago: ago,
DefaultError: herodot.ErrBadRequest.
WithError("login flow expired").
WithReasonf(`The login flow has expired. Please restart the flow.`).
WithReasonf("The login flow expired %.2f minutes ago, please try again.", ago.Minutes()),
a.UI.Messages.Add(text.NewErrorValidationLoginFlowExpired(e.Ago))
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), a); err != nil {
return nil, err
}
}

func NewFlowErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
return e.WithFlow(a), nil
}

func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f *Flow, group node.Group, err error) {
Expand All @@ -83,26 +82,14 @@ func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f
return
}

if e := new(FlowExpiredError); errors.As(err, &e) {
// create new flow because the old one is not valid
a, err := s.d.LoginHandler().FromOldFlow(w, r, *f)
if err != nil {
// failed to create a new session and redirect to it, handle that error as a new one
s.WriteFlowError(w, r, f, group, err)
return
}

a.UI.Messages.Add(text.NewErrorValidationLoginFlowExpired(e.ago))
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), a); err != nil {
s.forward(w, r, a, err)
return
}

if expired, inner := s.PrepareReplacementForExpiredFlow(w, r, f, err); inner != nil {
s.WriteFlowError(w, r, f, group, err)
return
} else if expired != nil {
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config(r.Context()).SelfPublicURL(r),
RouteGetFlow), url.Values{"id": {a.ID.String()}}).String(), http.StatusSeeOther)
s.d.Writer().WriteError(w, r, expired)
} else {
http.Redirect(w, r, a.AppendTo(s.d.Config(r.Context()).SelfServiceFlowLoginUI()).String(), http.StatusSeeOther)
http.Redirect(w, r,expired.GetFlow().AppendTo(s.d.Config(r.Context()).SelfServiceFlowLoginUI()).String(), http.StatusSeeOther)
}
return
}
Expand Down
4 changes: 2 additions & 2 deletions selfservice/flow/login/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestHandleError(t *testing.T) {
t.Cleanup(reset)

loginFlow = newFlow(t, time.Minute, tc.t)
flowError = login.NewFlowExpiredError(anHourAgo)
flowError = flow.NewFlowExpiredError(anHourAgo)
ct = node.PasswordGroup

res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
Expand Down Expand Up @@ -194,7 +194,7 @@ func TestHandleError(t *testing.T) {
t.Cleanup(reset)

loginFlow = &login.Flow{Type: flow.TypeBrowser}
flowError = login.NewFlowExpiredError(anHourAgo)
flowError = flow.NewFlowExpiredError(anHourAgo)
ct = node.PasswordGroup

lf, _ := expectLoginUI(t)
Expand Down
2 changes: 1 addition & 1 deletion selfservice/flow/login/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func (f Flow) WhereID(ctx context.Context, alias string) string {

func (f *Flow) Valid() error {
if f.ExpiresAt.Before(time.Now()) {
return errors.WithStack(NewFlowExpiredError(f.ExpiresAt))
return errors.WithStack(flow.NewFlowExpiredError(f.ExpiresAt))
}
return nil
}
Expand Down
24 changes: 21 additions & 3 deletions selfservice/flow/login/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ type initializeSelfServiceLoginFlowWithoutBrowser struct {
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
// you vulnerable to a variety of CSRF attacks, including CSRF login attacks.
//
// In the case of an error, the `error.id` of the JSON response body can be one of:
//
// - `has_session_already`: The user is already signed in.
// - `aal_needs_session`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
// - `csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
//
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
//
// More information can be found at [Ory Kratos User Login and User Registration Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-login-user-registration).
Expand Down Expand Up @@ -293,7 +299,12 @@ type initializeSelfServiceLoginFlowForBrowsers struct {
// exists already, the browser will be redirected to `urls.default_redirect_url` unless the query parameter
// `?refresh=true` was set.
//
// If this endpoint is called via an AJAX request, the response contains the login flow without a redirect.
// If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the
// case of an error, the `error.id` of the JSON response body can be one of:
//
// - `has_session_already`: The user is already signed in.
// - `aal_needs_session`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
// - `csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
//
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
//
Expand All @@ -307,6 +318,7 @@ type initializeSelfServiceLoginFlowForBrowsers struct {
// Responses:
// 200: selfServiceLoginFlow
// 302: emptyResponse
// 400: jsonError
// 500: jsonError
func (h *Handler) initBrowserFlow(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
a, err := h.NewLoginFlow(w, r, flow.TypeBrowser)
Expand Down Expand Up @@ -373,6 +385,12 @@ type getSelfServiceLoginFlow struct {
// })
// ```
//
// This request may fail due to several reasons. The `error.id` can be one of:
//
// - `has_session_already`: The user is already signed in.
// - `self_service_flow_expired`: The flow is expired and you should request a new one.
// - `forbidden_return_to`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
//
// More information can be found at [Ory Kratos User Login and User Registration Documentation](https://www.ory.sh/docs/next/kratos/self-service/flows/user-login-user-registration).
//
// Produces:
Expand Down Expand Up @@ -403,12 +421,12 @@ func (h *Handler) fetchFlow(w http.ResponseWriter, r *http.Request, _ httprouter

if ar.ExpiresAt.Before(time.Now()) {
if ar.Type == flow.TypeBrowser {
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Redirect the user to the login flow init endpoint to initialize a new login flow.").
WithDetail("redirect_to", urlx.AppendPaths(h.d.Config(r.Context()).SelfPublicURL(r), RouteInitBrowserFlow).String())))
return
}
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.
h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone.WithID(text.ErrIDSelfServiceFlowExpired).
WithReason("The login flow has expired. Call the login flow init API endpoint to initialize a new login flow.").
WithDetail("api", urlx.AppendPaths(h.d.Config(r.Context()).SelfPublicURL(r), RouteInitAPIFlow).String())))
return
Expand Down
65 changes: 26 additions & 39 deletions selfservice/flow/registration/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package registration

import (
"net/http"
"net/url"
"time"

"github.com/ory/kratos/ui/node"

Expand All @@ -13,16 +11,14 @@ import (
"github.com/pkg/errors"

"github.com/ory/herodot"
"github.com/ory/x/urlx"

"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/x"
)

var (
ErrHookAbortFlow = errors.New("aborted registration hook execution")
ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithReason("A valid session was detected and thus registration is not possible.")
ErrAlreadyLoggedIn = herodot.ErrBadRequest.WithID(text.ErrIDAlreadyLoggedIn).WithError("you are already logged in").WithReason("A valid session was detected and thus registration is not possible.")
)

type (
Expand All @@ -41,28 +37,31 @@ type (
ErrorHandler struct {
d errorHandlerDependencies
}

FlowExpiredError struct {
*herodot.DefaultError
ago time.Duration
}
)

func NewFlowExpiredError(at time.Time) *FlowExpiredError {
ago := time.Since(at)
return &FlowExpiredError{
ago: ago,
DefaultError: herodot.ErrBadRequest.
WithError("registration flow expired").
WithReasonf(`The registration flow has expired. Please restart the flow.`).
WithReasonf("The registration flow expired %.2f minutes ago, please try again.", ago.Minutes()),
}
}

func NewErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}


func (s *ErrorHandler) PrepareReplacementForExpiredFlow(w http.ResponseWriter, r *http.Request, f *Flow, err error) (*flow.ExpiredError, error) {
e := new(flow.ExpiredError)
if !errors.As(err, &e) {
return nil, nil
}
// create new flow because the old one is not valid
a, err := s.d.RegistrationHandler().FromOldFlow(w, r, *f)
if err != nil {
return nil, err
}

a.UI.Messages.Add(text.NewErrorValidationRegistrationFlowExpired(e.Ago))
if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(r.Context(), a); err != nil {
return nil, err
}

return e.WithFlow(a), nil
}
func (s *ErrorHandler) WriteFlowError(
w http.ResponseWriter,
r *http.Request,
Expand All @@ -81,26 +80,14 @@ func (s *ErrorHandler) WriteFlowError(
return
}

if e := new(FlowExpiredError); errors.As(err, &e) {
// create new flow because the old one is not valid
a, err := s.d.RegistrationHandler().FromOldFlow(w, r, *f)
if err != nil {
// failed to create a new session and redirect to it, handle that error as a new one
s.WriteFlowError(w, r, f, group, err)
return
}

a.UI.AddMessage(group, text.NewErrorValidationRegistrationFlowExpired(e.ago))
if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(r.Context(), a); err != nil {
s.forward(w, r, a, err)
return
}

if expired, inner := s.PrepareReplacementForExpiredFlow(w, r, f, err); inner != nil {
s.forward(w, r, f, err)
return
} else if expired != nil {
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config(r.Context()).SelfPublicURL(r),
RouteGetFlow), url.Values{"id": {a.ID.String()}}).String(), http.StatusSeeOther)
s.d.Writer().WriteError(w, r, expired)
} else {
http.Redirect(w, r, a.AppendTo(s.d.Config(r.Context()).SelfServiceFlowRegistrationUI()).String(), http.StatusSeeOther)
http.Redirect(w, r, expired.GetFlow().AppendTo(s.d.Config(r.Context()).SelfServiceFlowRegistrationUI()).String(), http.StatusSeeOther)
}
return
}
Expand Down
4 changes: 2 additions & 2 deletions selfservice/flow/registration/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func TestHandleError(t *testing.T) {
t.Cleanup(reset)

registrationFlow = newFlow(t, time.Minute, tc.t)
flowError = registration.NewFlowExpiredError(anHourAgo)
flowError = flow.NewFlowExpiredError(anHourAgo)
group = node.PasswordGroup

res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
Expand Down Expand Up @@ -194,7 +194,7 @@ func TestHandleError(t *testing.T) {
t.Cleanup(reset)

registrationFlow = &registration.Flow{Type: flow.TypeBrowser}
flowError = registration.NewFlowExpiredError(anHourAgo)
flowError = flow.NewFlowExpiredError(anHourAgo)
group = node.PasswordGroup

lf, _ := expectRegistrationUI(t)
Expand Down
2 changes: 1 addition & 1 deletion selfservice/flow/registration/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (f Flow) GetNID() uuid.UUID {

func (f *Flow) Valid() error {
if f.ExpiresAt.Before(time.Now()) {
return errors.WithStack(NewFlowExpiredError(f.ExpiresAt))
return errors.WithStack(flow.NewFlowExpiredError(f.ExpiresAt))
}
return nil
}
Expand Down

0 comments on commit 787558b

Please sign in to comment.