Skip to content

Commit

Permalink
Implement account linking in authui
Browse files Browse the repository at this point in the history
  • Loading branch information
louischan-oursky committed May 3, 2024
2 parents 84202a8 + 5a59526 commit 5fd66aa
Show file tree
Hide file tree
Showing 37 changed files with 1,466 additions and 104 deletions.
3 changes: 3 additions & 0 deletions docs/specs/authentication-flow-api-reference.md
Expand Up @@ -399,6 +399,7 @@ During identification steps in signup flow, an account linking could be triggere
"identification": "oauth",
"data": {
"type": "account_linking_identification_data",
"account_linking_action": "login_and_link",
"options": [
{
"identification": "email",
Expand All @@ -419,6 +420,8 @@ During identification steps in signup flow, an account linking could be triggere

This means account linking was triggered by the previously identified identity. You can find the followings in `action.data`:

- `account_linking_action`: This field specify what is going to happen in this account linking. The only possible value in current version is `login_and_link`.
- `login_and_link`: You need to login to one of the account in `options`. After that, the identity you have just created in previous steps will be linked to the logged in account.
- `options`: Contains options that you can use to continue the account linking flow. The items contains the following fields:
- `identification`: See [type: signup; action.type: identification](#type-signup-actiontype-identification). They are having the same meaning.
- `masked_display_name`: The display name of the identity to use. Different from signup flow, during account linking, you must use an existing identity to start account linking. The display name here is the display name of the referred identity of this option. If it is an `email`, a masked email will be displayed. If it is a `phone`, a masked phone number will be displayed. If it is a `username`, the username will be displayed without masking. If it is a `oauth` identity, the display name will be a name which you should be able to recongize the account in that provider.
Expand Down
3 changes: 2 additions & 1 deletion pkg/auth/handler/webapp/authflow_controller.go
Expand Up @@ -1004,7 +1004,8 @@ func (c *AuthflowController) makeErrorResult(w http.ResponseWriter, r *http.Requ
fallthrough
case apierrors.IsKind(err, webapp.WebUIInvalidSession):
fallthrough
case r.Method == http.MethodGet:
case r.Method == http.MethodGet && u.Path == r.URL.Path:
// Infinite loop might occur if it is a GET request with the same route
c.Navigator.NavigateNonRecoverableError(r, &u, err)
return nonRecoverable()
default:
Expand Down
149 changes: 149 additions & 0 deletions pkg/auth/handler/webapp/authflowv2/account_linking.go
@@ -0,0 +1,149 @@
package authflowv2

import (
"fmt"
"net/http"
"strconv"

handlerwebapp "github.com/authgear/authgear-server/pkg/auth/handler/webapp"
"github.com/authgear/authgear-server/pkg/auth/handler/webapp/viewmodels"
"github.com/authgear/authgear-server/pkg/auth/webapp"
"github.com/authgear/authgear-server/pkg/lib/authenticationflow/declarative"
"github.com/authgear/authgear-server/pkg/lib/config"
"github.com/authgear/authgear-server/pkg/util/httproute"
"github.com/authgear/authgear-server/pkg/util/template"
"github.com/authgear/authgear-server/pkg/util/validation"
)

var TemplateWebAuthflowV2AccountLinkingHTML = template.RegisterHTML(
"web/authflowv2/account_linking.html",
handlerwebapp.Components...,
)

var AuthflowV2AccountLinkingIdentifySchema = validation.NewSimpleSchema(`
{
"type": "object",
"properties": {
"x_index": { "type": "string" }
},
"required": ["x_index"]
}
`)

func ConfigureAuthflowV2AccountLinkingRoute(route httproute.Route) httproute.Route {
return route.
WithMethods("OPTIONS", "POST", "GET").
WithPathPattern(AuthflowV2RouteAccountLinking)
}

type AuthflowV2AccountLinkingOption struct {
Identification config.AuthenticationFlowIdentification
MaskedDisplayName string
ProviderType config.OAuthSSOProviderType
Index int
}

type AuthflowV2AccountLinkingViewModel struct {
Action string
Options []AuthflowV2AccountLinkingOption
}

type AuthflowV2AccountLinkingHandler struct {
Controller *handlerwebapp.AuthflowController
BaseViewModel *viewmodels.BaseViewModeler
Renderer handlerwebapp.Renderer
Endpoints handlerwebapp.AuthflowSignupEndpointsProvider
}

func NewAuthflowV2AccountLinkingViewModel(s *webapp.Session, screen *webapp.AuthflowScreenWithFlowResponse) AuthflowV2AccountLinkingViewModel {
flowResponse := screen.StateTokenFlowResponse
data := flowResponse.Action.Data.(declarative.AccountLinkingIdentifyData)

options := []AuthflowV2AccountLinkingOption{}

for idx, option := range data.Options {
idx := idx
option := option

options = append(options, AuthflowV2AccountLinkingOption{
Identification: option.Identifcation,
MaskedDisplayName: option.MaskedDisplayName,
ProviderType: option.ProviderType,
Index: idx,
})
}

return AuthflowV2AccountLinkingViewModel{
Action: string(data.AccountLinkingAction),
Options: options,
}
}

func (h *AuthflowV2AccountLinkingHandler) GetData(w http.ResponseWriter, r *http.Request, s *webapp.Session, screen *webapp.AuthflowScreenWithFlowResponse) (map[string]interface{}, error) {
data := make(map[string]interface{})

baseViewModel := h.BaseViewModel.ViewModelForAuthFlow(r, w)
viewmodels.Embed(data, baseViewModel)

screenViewModel := NewAuthflowV2AccountLinkingViewModel(s, screen)
viewmodels.Embed(data, screenViewModel)

return data, nil
}

func (h *AuthflowV2AccountLinkingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var handlers handlerwebapp.AuthflowControllerHandlers
handlers.Get(func(s *webapp.Session, screen *webapp.AuthflowScreenWithFlowResponse) error {
data, err := h.GetData(w, r, s, screen)
if err != nil {
return err
}

h.Renderer.RenderHTML(w, r, TemplateWebAuthflowV2AccountLinkingHTML, data)
return nil
})
handlers.PostAction("", func(s *webapp.Session, screen *webapp.AuthflowScreenWithFlowResponse) error {
err := AuthflowV2AccountLinkingIdentifySchema.Validator().ValidateValue(handlerwebapp.FormToJSON(r.Form))
if err != nil {
return err
}

index, err := strconv.Atoi(r.Form.Get("x_index"))
if err != nil {
return err
}
flowResponse := screen.StateTokenFlowResponse
data := flowResponse.Action.Data.(declarative.AccountLinkingIdentifyData)
option := data.Options[index]

var input map[string]interface{}
switch option.Identifcation {
case config.AuthenticationFlowIdentificationEmail:
fallthrough
case config.AuthenticationFlowIdentificationPhone:
fallthrough
case config.AuthenticationFlowIdentificationUsername:
input = map[string]interface{}{
"index": index,
}
case config.AuthenticationFlowIdentificationOAuth:
providerAlias := option.Alias
input = map[string]interface{}{
"index": index,
"redirect_uri": h.Endpoints.SSOCallbackURL(providerAlias).String(),
}
default:
panic(fmt.Errorf("unsupported identifcation option %v", option.Identifcation))
}

result, err := h.Controller.AdvanceWithInput(r, s, screen, input, nil)
if err != nil {
return err
}

result.WriteResponse(w, r)
return nil
})

h.Controller.HandleStep(w, r, &handlers)
}
1 change: 1 addition & 0 deletions pkg/auth/handler/webapp/authflowv2/deps.go
Expand Up @@ -37,4 +37,5 @@ var DependencySet = wire.NewSet(
wire.Struct(new(AuthflowV2PromoteHandler), "*"),
wire.Struct(new(AuthflowV2FinishFlowHandler), "*"),
wire.Struct(new(AuthflowV2WechatHandler), "*"),
wire.Struct(new(AuthflowV2AccountLinkingHandler), "*"),
)
10 changes: 9 additions & 1 deletion pkg/auth/handler/webapp/authflowv2/routes.go
Expand Up @@ -59,6 +59,7 @@ const (
// nolint: gosec
AuthflowV2RouteResetPasswordSuccess = "/authflow/v2/reset_password/success"
AuthflowV2RouteWechat = "/authflow/v2/wechat"
AuthflowV2RouteAccountLinking = "/authflow/v2/account_linking"

// The following routes are dead ends.
AuthflowV2RouteAccountStatus = "/authflow/v2/account_status"
Expand Down Expand Up @@ -250,6 +251,11 @@ func (n *AuthflowV2Navigator) navigateSignupPromote(s *webapp.AuthflowScreenWith
}

func (n *AuthflowV2Navigator) navigateStepIdentify(s *webapp.AuthflowScreenWithFlowResponse, r *http.Request, webSessionID string, result *webapp.Result, expectedPath string) {
if _, ok := s.StateTokenFlowResponse.Action.Data.(declarative.AccountLinkingIdentifyData); ok {
s.Advance(AuthflowV2RouteAccountLinking, result)
return
}

identification := s.StateTokenFlowResponse.Action.Identification
switch identification {
case "":
Expand Down Expand Up @@ -281,11 +287,13 @@ func (n *AuthflowV2Navigator) navigateStepIdentify(s *webapp.AuthflowScreenWithF
default:
authorizationURL, _ := url.Parse(data.OAuthAuthorizationURL)
q := authorizationURL.Query()
// Back to the current screen if error
errorRedirectURI := url.URL{Path: r.URL.Path, RawQuery: r.URL.Query().Encode()}

state := webapp.AuthflowOAuthState{
WebSessionID: webSessionID,
XStep: s.Screen.StateToken.XStep,
ErrorRedirectURI: expectedPath,
ErrorRedirectURI: errorRedirectURI.String(),
}

q.Set("state", state.Encode())
Expand Down
1 change: 1 addition & 0 deletions pkg/auth/routes.go
Expand Up @@ -318,6 +318,7 @@ func NewRouter(p *deps.RootProvider, configSource *configsource.ConfigSource) *h
router.Add(webapphandlerauthflowv2.ConfigureAuthflowV2NoAuthenticatorRoute(webappPageRoute), p.Handler(newWebAppAuthflowV2NoAuthenticatorHandler))
router.Add(webapphandlerauthflowv2.ConfigureAuthflowv2FinishFlowRoute(webappPageRoute), p.Handler(newWebAppAuthflowV2FinishFlowHandler))
router.Add(webapphandlerauthflowv2.ConfigureAuthflowV2WechatRoute(webappPageRoute), p.Handler(newWebAppAuthflowV2WechatHandler))
router.Add(webapphandlerauthflowv2.ConfigureAuthflowV2AccountLinkingRoute(webappPageRoute), p.Handler(newWebAppAuthflowV2AccountLinkingHandler))

router.Add(webapphandler.ConfigureAuthflowEnterPasswordRoute(webappPageRoute), p.Handler(newWebAppAuthflowEnterPasswordHandler))
router.Add(webapphandler.ConfigureAuthflowEnterOOBOTPRoute(webappPageRoute), p.Handler(newWebAppAuthflowEnterOOBOTPHandler))
Expand Down

0 comments on commit 5fd66aa

Please sign in to comment.