Repository: SkygearIO/skygear-server
Pull request #1405 (2020-05-13 10:10:38)

Implement anonymous user promotion
requires #1407 
ref #1376 


Patch:
diff --git a/cmd/auth/main.go b/cmd/auth/main.go
index b001f18d..d17d9121 100644
--- a/cmd/auth/main.go
+++ b/cmd/auth/main.go
@@ -257,6 +257,8 @@ func main() {
 	webapphandler.AttachRootHandler(webappAuthEntryPointRouter, authDependency)
 	webapphandler.AttachLoginHandler(webappAuthEntryPointRouter, authDependency)
 	webapphandler.AttachSignupHandler(webappAuthEntryPointRouter, authDependency)
+	webapphandler.AttachPromoteHandler(webappAuthEntryPointRouter, authDependency)
+
 	webapphandler.AttachEnterPasswordHandler(webappAuthRouter, authDependency)
 	webapphandler.AttachEnterLoginIDHandler(webappAuthRouter, authDependency)
 	webapphandler.AttachOOBOTPHandler(webappAuthRouter, authDependency)
diff --git a/pkg/auth/dependency/identity/provider/provider.go b/pkg/auth/dependency/identity/provider/provider.go
index df5815b4..296d47ae 100644
--- a/pkg/auth/dependency/identity/provider/provider.go
+++ b/pkg/auth/dependency/identity/provider/provider.go
@@ -51,9 +51,11 @@ type OAuthIdentityProvider interface {
 type AnonymousIdentityProvider interface {
 	Get(userID, id string) (*anonymous.Identity, error)
 	GetByKeyID(keyID string) (*anonymous.Identity, error)
+	List(userID string) ([]*anonymous.Identity, error)
 	ListByClaim(name string, value string) ([]*anonymous.Identity, error)
 	New(userID string, keyID string, key []byte) *anonymous.Identity
 	Create(i *anonymous.Identity) error
+	Delete(i *anonymous.Identity) error
 }
 
 type Provider struct {
@@ -138,6 +140,14 @@ func (a *Provider) GetByUserAndClaims(typ authn.IdentityType, userID string, cla
 			return nil, err
 		}
 		return oauthToIdentityInfo(o), nil
+	case authn.IdentityTypeAnonymous:
+		as, err := a.Anonymous.List(userID)
+		if err != nil {
+			return nil, err
+		} else if len(as) == 0 {
+			return nil, identity.ErrIdentityNotFound
+		}
+		return anonymousToIdentityInfo(as[0]), nil
 	default:
 		uid, iden, err := a.GetByClaims(typ, claims)
 		if err != nil {
@@ -198,6 +208,15 @@ func (a *Provider) ListByUser(userID string) ([]*identity.Info, error) {
 		iis = append(iis, oauthToIdentityInfo(i))
 	}
 
+	// anonymous
+	ais, err := a.Anonymous.List(userID)
+	if err != nil {
+		return nil, err
+	}
+	for _, i := range ais {
+		iis = append(iis, anonymousToIdentityInfo(i))
+	}
+
 	return iis, nil
 }
 
@@ -317,6 +336,11 @@ func (a *Provider) DeleteAll(userID string, is []*identity.Info) error {
 			if err := a.OAuth.Delete(identity); err != nil {
 				return err
 			}
+		case authn.IdentityTypeAnonymous:
+			identity := anonymousFromIdentityInfo(userID, i)
+			if err := a.Anonymous.Delete(identity); err != nil {
+				return err
+			}
 		default:
 			panic("interaction_adaptors: unknown identity type " + i.Type)
 		}
diff --git a/pkg/auth/dependency/interaction/flows/anonymous.go b/pkg/auth/dependency/interaction/flows/anonymous.go
index 47d0cc0c..db9f92b8 100644
--- a/pkg/auth/dependency/interaction/flows/anonymous.go
+++ b/pkg/auth/dependency/interaction/flows/anonymous.go
@@ -101,3 +101,18 @@ func (f *AnonymousFlow) Authenticate(requestJWT string, clientID string) (*authn
 
 	return attrs, nil
 }
+
+func (f *AnonymousFlow) DecodeUserID(requestJWT string) (string, anonymous.RequestAction, error) {
+	identity, request, err := f.Anonymous.ParseRequest(requestJWT)
+	if err != nil || identity == nil {
+		return "", "", interaction.ErrInvalidCredentials
+	}
+
+	// Verify challenge token
+	purpose, err := f.Challenges.Consume(request.Challenge)
+	if err != nil || *purpose != challenge.PurposeAnonymousRequest {
+		return "", "", interaction.ErrInvalidCredentials
+	}
+
+	return identity.UserID, request.Action, nil
+}
diff --git a/pkg/auth/dependency/interaction/flows/auth_api_sso.go b/pkg/auth/dependency/interaction/flows/auth_api_sso.go
index a78a5ce6..944e7136 100644
--- a/pkg/auth/dependency/interaction/flows/auth_api_sso.go
+++ b/pkg/auth/dependency/interaction/flows/auth_api_sso.go
@@ -16,8 +16,8 @@ import (
 )
 
 const (
-	// AuthAPIStateOAuthCodeChallenge is a claim with string value for sso code challenge of current interaction in auth api
-	AuthAPIStateOAuthCodeChallenge string = "https://auth.skygear.io/claims/auth_api/sso/code_challenge"
+	// AuthAPIExtraStateOAuthCodeChallenge is a extra state with string value for sso code challenge of current interaction in auth api
+	AuthAPIExtraStateOAuthCodeChallenge string = "https://auth.skygear.io/claims/auth_api/sso/code_challenge"
 )
 
 func (f *AuthAPIFlow) LoginWithOAuthProvider(
@@ -37,7 +37,7 @@ func (f *AuthAPIFlow) LoginWithOAuthProvider(
 		},
 	}, clientID)
 	if err == nil {
-		i.State[AuthAPIStateOAuthCodeChallenge] = codeChallenge
+		i.Extra[AuthAPIExtraStateOAuthCodeChallenge] = codeChallenge
 		return f.Interactions.SaveInteraction(i)
 	}
 	if !errors.Is(err, interaction.ErrInvalidCredentials) {
@@ -84,7 +84,7 @@ func (f *AuthAPIFlow) LoginWithOAuthProvider(
 	if err != nil {
 		return "", err
 	}
-	i.State[AuthAPIStateOAuthCodeChallenge] = codeChallenge
+	i.Extra[AuthAPIExtraStateOAuthCodeChallenge] = codeChallenge
 	return f.Interactions.SaveInteraction(i)
 }
 
@@ -120,7 +120,7 @@ func (f *AuthAPIFlow) LinkWithOAuthProvider(
 		panic("interaction_flow_auth_api: unexpected interaction step")
 	}
 
-	i.State[AuthAPIStateOAuthCodeChallenge] = codeChallenge
+	i.Extra[AuthAPIExtraStateOAuthCodeChallenge] = codeChallenge
 	return f.Interactions.SaveInteraction(i)
 }
 
@@ -130,7 +130,7 @@ func (f *AuthAPIFlow) ExchangeCode(interactionToken string, verifier string) (*A
 		return nil, err
 	}
 
-	challenge := i.State[AuthAPIStateOAuthCodeChallenge]
+	challenge := i.Extra[AuthAPIExtraStateOAuthCodeChallenge]
 	// challenge can be empty for api login with access token flow
 	if challenge != "" {
 		if err := verifyPKCE(challenge, verifier); err != nil {
diff --git a/pkg/auth/dependency/interaction/flows/webapp.go b/pkg/auth/dependency/interaction/flows/webapp.go
index bc08ccd1..5d466393 100644
--- a/pkg/auth/dependency/interaction/flows/webapp.go
+++ b/pkg/auth/dependency/interaction/flows/webapp.go
@@ -114,6 +114,59 @@ func (f *WebAppFlow) SignupWithLoginID(loginIDKey, loginID string) (*WebAppResul
 	}, nil
 }
 
+func (f *WebAppFlow) PromoteWithLoginID(loginIDKey, loginID string, userID string) (*WebAppResult, error) {
+	i, err := f.Interactions.NewInteractionAddIdentity(&interaction.IntentAddIdentity{
+		Identity: identity.Spec{
+			Type: authn.IdentityTypeLoginID,
+			Claims: map[string]interface{}{
+				identity.IdentityClaimLoginIDKey:   loginIDKey,
+				identity.IdentityClaimLoginIDValue: loginID,
+			},
+		},
+	}, "", userID)
+	if err != nil {
+		return nil, err
+	}
+
+	s, err := f.Interactions.GetInteractionState(i)
+	if err != nil {
+		return nil, err
+	}
+
+	if s.CurrentStep().Step != interaction.StepSetupPrimaryAuthenticator || len(s.CurrentStep().AvailableAuthenticators) <= 0 {
+		panic("interaction_flow_webapp: unexpected interaction state")
+	}
+
+	var step WebAppStep
+
+	switch s.CurrentStep().AvailableAuthenticators[0].Type {
+	case authn.AuthenticatorTypeOOB:
+		step = WebAppStepSetupOOBOTP
+		err = f.Interactions.PerformAction(i, interaction.StepSetupPrimaryAuthenticator, &interaction.ActionTriggerOOBAuthenticator{
+			Authenticator: s.CurrentStep().AvailableAuthenticators[0],
+		})
+		if err != nil {
+			return nil, err
+		}
+	case authn.AuthenticatorTypePassword:
+		step = WebAppStepSetupPassword
+	default:
+		panic("interaction_flow_webapp: unexpected authenticator type")
+	}
+
+	i.Extra[WebAppExtraStateAnonymousUserPromotion] = "true"
+
+	token, err := f.Interactions.SaveInteraction(i)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WebAppResult{
+		Step:  step,
+		Token: token,
+	}, nil
+}
+
 func (f *WebAppFlow) EnterSecret(token string, secret string) (*WebAppResult, error) {
 	i, err := f.Interactions.GetInteraction(token)
 	if err != nil {
@@ -201,6 +254,15 @@ func (f *WebAppFlow) SetupSecret(token string, secret string) (*WebAppResult, er
 		// Primary authentication is done using `AuthenticatedAs`
 		return f.afterPrimaryAuthentication(i)
 
+	case interaction.IntentTypeAddIdentity:
+		if i.Extra[WebAppExtraStateAnonymousUserPromotion] == "true" {
+			return f.afterAnonymousUserPromotion(attrs)
+		}
+
+		return &WebAppResult{
+			Step: WebAppStepCompleted,
+		}, nil
+
 	default:
 		return &WebAppResult{
 			Step: WebAppStepCompleted,
diff --git a/pkg/auth/dependency/interaction/flows/webapp_promote.go b/pkg/auth/dependency/interaction/flows/webapp_promote.go
new file mode 100644
index 00000000..f9554e6d
--- /dev/null
+++ b/pkg/auth/dependency/interaction/flows/webapp_promote.go
@@ -0,0 +1,45 @@
+package flows
+
+import (
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/interaction"
+	"github.com/skygeario/skygear-server/pkg/core/authn"
+)
+
+const (
+	// WebAppExtraStatePromotion is a extra state indicating the interaction
+	// is for anonymous user promotion.
+	WebAppExtraStateAnonymousUserPromotion string = "https://auth.skygear.io/claims/web_app/anonymous_user_promotion"
+)
+
+func (f *WebAppFlow) afterAnonymousUserPromotion(attrs *authn.Attrs) (*WebAppResult, error) {
+	// Remove anonymous identity
+	i, err := f.Interactions.NewInteractionRemoveIdentity(&interaction.IntentRemoveIdentity{
+		Identity: identity.Spec{
+			Type:   authn.IdentityTypeAnonymous,
+			Claims: map[string]interface{}{},
+		},
+	}, "", attrs.UserID)
+	if err != nil {
+		return nil, err
+	}
+
+	s, err := f.Interactions.GetInteractionState(i)
+	if err != nil {
+		return nil, err
+	}
+
+	if s.CurrentStep().Step != interaction.StepCommit {
+		panic("interaction_flow_webapp: unexpected step " + s.CurrentStep().Step)
+	}
+
+	result, err := f.afterPrimaryAuthentication(i)
+	if err != nil {
+		return nil, err
+	}
+
+	// NOTE: existing anonymous sessions are not deleted, in case of commit
+	// failure may cause lost users.
+
+	return result, nil
+}
diff --git a/pkg/auth/dependency/interaction/flows/webapp_sso.go b/pkg/auth/dependency/interaction/flows/webapp_sso.go
index aa126755..0d0dce1a 100644
--- a/pkg/auth/dependency/interaction/flows/webapp_sso.go
+++ b/pkg/auth/dependency/interaction/flows/webapp_sso.go
@@ -154,3 +154,40 @@ func (f *WebAppFlow) UnlinkWithOAuthProvider(userID string, providerConfig confi
 
 	return
 }
+
+func (f *WebAppFlow) PromoteWithOAuthProvider(userID string, oauthAuthInfo sso.AuthInfo) (*WebAppResult, error) {
+	providerID := oauth.NewProviderID(oauthAuthInfo.ProviderConfig)
+	claims := map[string]interface{}{
+		identity.IdentityClaimOAuthProvider:  providerID.ClaimsValue(),
+		identity.IdentityClaimOAuthSubjectID: oauthAuthInfo.ProviderUserInfo.ID,
+		identity.IdentityClaimOAuthProfile:   oauthAuthInfo.ProviderRawProfile,
+		identity.IdentityClaimOAuthClaims:    oauthAuthInfo.ProviderUserInfo.ClaimsValue(),
+	}
+
+	clientID := ""
+	i, err := f.Interactions.NewInteractionAddIdentity(&interaction.IntentAddIdentity{
+		Identity: identity.Spec{
+			Type:   authn.IdentityTypeOAuth,
+			Claims: claims,
+		},
+	}, clientID, userID)
+	if err != nil {
+		return nil, err
+	}
+
+	s, err := f.Interactions.GetInteractionState(i)
+	if err != nil {
+		return nil, err
+	} else if s.CurrentStep().Step != interaction.StepCommit {
+		// authenticator is not needed for oauth identity
+		// so the current step must be commit
+		panic("interaction_flow_webapp: unexpected interaction step")
+	}
+
+	attrs, err := f.Interactions.Commit(i)
+	if err != nil {
+		return nil, err
+	}
+
+	return f.afterAnonymousUserPromotion(attrs)
+}
diff --git a/pkg/auth/dependency/interaction/interaction.go b/pkg/auth/dependency/interaction/interaction.go
index ccd29955..45beb5c3 100644
--- a/pkg/auth/dependency/interaction/interaction.go
+++ b/pkg/auth/dependency/interaction/interaction.go
@@ -35,11 +35,23 @@ type Interaction struct {
 	RemoveIdentities     []*identity.Info      `json:"remove_identities,omitempty"`
 	RemoveAuthenticators []*authenticator.Info `json:"remove_authenticators,omitempty"`
 
+	// Extra is used to persist extra data across the interaction.
+	Extra map[string]string `json:"extra,omitempty"`
+
 	// The following fields are for checking programming errors.
 	saved     bool
 	committed bool
 }
 
+func newInteraction(clientID string, intent Intent) *Interaction {
+	return &Interaction{
+		ClientID: clientID,
+		Intent:   intent,
+		State:    map[string]string{},
+		Extra:    map[string]string{},
+	}
+}
+
 func (i *Interaction) IsNewIdentity(id string) bool {
 	for _, identity := range i.NewIdentities {
 		if identity.ID == id {
diff --git a/pkg/auth/dependency/interaction/provider_action.go b/pkg/auth/dependency/interaction/provider_action.go
index 587201a9..d5a33fda 100644
--- a/pkg/auth/dependency/interaction/provider_action.go
+++ b/pkg/auth/dependency/interaction/provider_action.go
@@ -187,7 +187,7 @@ func (p *Provider) doAuthenticate(i *Interaction, step *StepState, astate *map[s
 	i.UserID = userID
 	ir := iden.ToRef()
 	i.Identity = &ir
-	i.State = nil
+	i.State = map[string]string{}
 	return authen, nil
 }
 
@@ -223,7 +223,7 @@ func (p *Provider) setupAuthenticator(i *Interaction, step *StepState, astate *m
 		return nil, err
 	}
 	i.NewAuthenticators = append(i.NewAuthenticators, ais...)
-	i.State = nil
+	i.State = map[string]string{}
 	return ais[0], nil
 }
 
diff --git a/pkg/auth/dependency/interaction/provider_intent.go b/pkg/auth/dependency/interaction/provider_intent.go
index d7bc08bb..da17b945 100644
--- a/pkg/auth/dependency/interaction/provider_intent.go
+++ b/pkg/auth/dependency/interaction/provider_intent.go
@@ -57,20 +57,13 @@ func (p *Provider) NewInteractionLogin(intent *IntentLogin, clientID string) (*I
 }
 
 func (p *Provider) newInteractionLogin(intent *IntentLogin, clientID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:   intent,
-		ClientID: clientID,
-	}
+	i := newInteraction(clientID, intent)
 	return i, nil
 }
 
 func (p *Provider) newInteractionLoginRequireIdentity(intent *IntentLogin, clientID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:               intent,
-		ClientID:             clientID,
-		PrimaryAuthenticator: nil,
-		State:                map[string]string{},
-	}
+	i := newInteraction(clientID, intent)
+
 	userid, iden, err := p.Identity.GetByClaims(intent.Identity.Type, intent.Identity.Claims)
 	if errors.Is(err, identity.ErrIdentityNotFound) {
 		return nil, ErrInvalidCredentials
@@ -84,11 +77,9 @@ func (p *Provider) newInteractionLoginRequireIdentity(intent *IntentLogin, clien
 }
 
 func (p *Provider) NewInteractionSignup(intent *IntentSignup, clientID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:   intent,
-		ClientID: clientID,
-		UserID:   uuid.New(),
-	}
+	i := newInteraction(clientID, intent)
+	i.UserID = uuid.New()
+
 	identity := p.Identity.New(i.UserID, intent.Identity.Type, intent.Identity.Claims)
 	ir := identity.ToRef()
 	i.Identity = &ir
@@ -101,12 +92,9 @@ func (p *Provider) NewInteractionSignup(intent *IntentSignup, clientID string) (
 }
 
 func (p *Provider) NewInteractionAddIdentity(intent *IntentAddIdentity, clientID string, userID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:   intent,
-		ClientID: clientID,
-		UserID:   userID,
-		State:    map[string]string{},
-	}
+	i := newInteraction(clientID, intent)
+	i.UserID = userID
+
 	id := p.Identity.New(i.UserID, intent.Identity.Type, intent.Identity.Claims)
 	ir := id.ToRef()
 	i.Identity = &ir
@@ -134,11 +122,8 @@ func (p *Provider) NewInteractionAddIdentity(intent *IntentAddIdentity, clientID
 }
 
 func (p *Provider) NewInteractionUpdateIdentity(intent *IntentUpdateIdentity, clientID string, userID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:   intent,
-		ClientID: clientID,
-		UserID:   userID,
-	}
+	i := newInteraction(clientID, intent)
+	i.UserID = userID
 
 	if intent.OldIdentity.Type != intent.NewIdentity.Type {
 		panic("interaction: update identity type is not expected")
@@ -178,11 +163,9 @@ func (p *Provider) NewInteractionUpdateIdentity(intent *IntentUpdateIdentity, cl
 }
 
 func (p *Provider) NewInteractionRemoveIdentity(intent *IntentRemoveIdentity, clientID string, userID string) (*Interaction, error) {
-	i := &Interaction{
-		Intent:   intent,
-		ClientID: clientID,
-		UserID:   userID,
-	}
+	i := newInteraction(clientID, intent)
+	i.UserID = userID
+
 	iden, err := p.Identity.GetByUserAndClaims(intent.Identity.Type, userID, intent.Identity.Claims)
 	if errors.Is(err, identity.ErrIdentityNotFound) {
 		return nil, ErrIdentityNotFound
diff --git a/pkg/auth/dependency/oauth/endpoints.go b/pkg/auth/dependency/oauth/endpoints.go
index 82413a37..9a02a692 100644
--- a/pkg/auth/dependency/oauth/endpoints.go
+++ b/pkg/auth/dependency/oauth/endpoints.go
@@ -13,15 +13,3 @@ type TokenEndpointProvider interface {
 type RevokeEndpointProvider interface {
 	RevokeEndpointURI() *url.URL
 }
-
-type AuthenticateEndpointProvider interface {
-	AuthenticateEndpointURI() *url.URL
-}
-
-type LogoutEndpointProvider interface {
-	LogoutEndpointURI() *url.URL
-}
-
-type SettingsEndpointProvider interface {
-	SettingsEndpointURI() *url.URL
-}
diff --git a/pkg/auth/dependency/oauth/handler/deps.go b/pkg/auth/dependency/oauth/handler/deps.go
index dea32cdb..42b715c0 100644
--- a/pkg/auth/dependency/oauth/handler/deps.go
+++ b/pkg/auth/dependency/oauth/handler/deps.go
@@ -21,8 +21,8 @@ func ProvideAuthorizationHandler(
 	lf logging.Factory,
 	as oauth.AuthorizationStore,
 	cs oauth.CodeGrantStore,
-	authze oauth.AuthorizeEndpointProvider,
-	authne oauth.AuthenticateEndpointProvider,
+	authze AuthorizeURLProvider,
+	authne AuthenticateURLProvider,
 	vs ScopesValidator,
 	cg TokenGenerator,
 	tp time.Provider,
@@ -33,13 +33,13 @@ func ProvideAuthorizationHandler(
 		Clients: cfg.AppConfig.Clients,
 		Logger:  lf.NewLogger("oauth-authz"),
 
-		Authorizations:       as,
-		CodeGrants:           cs,
-		AuthorizeEndpoint:    authze,
-		AuthenticateEndpoint: authne,
-		ValidateScopes:       vs,
-		CodeGenerator:        cg,
-		Time:                 tp,
+		Authorizations:  as,
+		CodeGrants:      cs,
+		AuthorizeURL:    authze,
+		AuthenticateURL: authne,
+		ValidateScopes:  vs,
+		CodeGenerator:   cg,
+		Time:            tp,
 	}
 }
 
@@ -84,4 +84,6 @@ var DependencySet = wire.NewSet(
 	wire.Value(TokenGenerator(oauth.GenerateToken)),
 	wire.Bind(new(authn.TokenIssuer), new(*TokenHandler)),
 	wire.Bind(new(interactionflows.AuthAPITokenIssuer), new(*TokenHandler)),
+	wire.Struct(new(URLProvider), "*"),
+	wire.Bind(new(AuthorizeURLProvider), new(*URLProvider)),
 )
diff --git a/pkg/auth/dependency/oauth/handler/handler_authz.go b/pkg/auth/dependency/oauth/handler/handler_authz.go
index e0cf8e8a..83d5a9a8 100644
--- a/pkg/auth/dependency/oauth/handler/handler_authz.go
+++ b/pkg/auth/dependency/oauth/handler/handler_authz.go
@@ -4,33 +4,43 @@ import (
 	"context"
 	"errors"
 	"net/url"
+	"strings"
 	gotime "time"
 
 	"github.com/sirupsen/logrus"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/auth"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/interaction"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/protocol"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/webapp"
 	"github.com/skygeario/skygear-server/pkg/core/config"
 	"github.com/skygeario/skygear-server/pkg/core/time"
-	coreurl "github.com/skygeario/skygear-server/pkg/core/url"
 	"github.com/skygeario/skygear-server/pkg/core/utils"
 )
 
 const CodeGrantValidDuration = 5 * gotime.Minute
 
+type AuthorizeURLProvider interface {
+	AuthorizeURI(r protocol.AuthorizationRequest) *url.URL
+}
+
+type AuthenticateURLProvider interface {
+	AuthenticateURI(options webapp.AuthenticateURLOptions) (*url.URL, error)
+}
+
 type AuthorizationHandler struct {
 	Context context.Context
 	AppID   string
 	Clients []config.OAuthClientConfiguration
 	Logger  *logrus.Entry
 
-	Authorizations       oauth.AuthorizationStore
-	CodeGrants           oauth.CodeGrantStore
-	AuthorizeEndpoint    oauth.AuthorizeEndpointProvider
-	AuthenticateEndpoint oauth.AuthenticateEndpointProvider
-	ValidateScopes       ScopesValidator
-	CodeGenerator        TokenGenerator
-	Time                 time.Provider
+	Authorizations  oauth.AuthorizationStore
+	CodeGrants      oauth.CodeGrantStore
+	AuthorizeURL    AuthorizeURLProvider
+	AuthenticateURL AuthenticateURLProvider
+	ValidateScopes  ScopesValidator
+	CodeGenerator   TokenGenerator
+	Time            time.Provider
 }
 
 func (h *AuthorizationHandler) Handle(r protocol.AuthorizationRequest) AuthorizationResult {
@@ -82,14 +92,7 @@ func (h *AuthorizationHandler) doHandle(
 	}
 
 	session := auth.GetSession(h.Context)
-	if session == nil || session.SessionType() != auth.SessionTypeIdentityProvider {
-		// Not authenticated as IdP session => request authentication and retry
-		return authorizationResultRequireAuthn{
-			AuthenticateURI: h.AuthenticateEndpoint.AuthenticateEndpointURI(),
-			AuthorizeURI:    h.AuthorizeEndpoint.AuthorizeEndpointURI(),
-			Request:         r,
-		}, nil
-	}
+	authnOptions := webapp.AuthenticateURLOptions{}
 	if utils.StringSliceContains(r.Prompt(), "login") {
 		// Request login prompt => force re-authentication and retry
 		r2 := protocol.AuthorizationRequest{}
@@ -98,15 +101,30 @@ func (h *AuthorizationHandler) doHandle(
 		}
 		prompt := utils.StringSliceExcept(r.Prompt(), []string{"login"})
 		r2.SetPrompt(prompt)
+		authnOptions.Prompt = "login"
+
+		r = r2
+		// Treat as not authenticated
+		session = nil
+	}
+	if session == nil || session.SessionType() != auth.SessionTypeIdentityProvider {
+		// Not authenticated as IdP session => request authentication and retry
+		authnOptions.ClientID = r.ClientID()
+		authnOptions.UILocales = strings.Join(r.UILocales(), " ")
+		authnOptions.LoginHint = r.LoginHint()
+		r.SetLoginHint("")
+		authorizeURI := h.AuthorizeURL.AuthorizeURI(r)
+		authnOptions.RedirectURI = authorizeURI.String()
+
+		authenticateURI, err := h.AuthenticateURL.AuthenticateURI(authnOptions)
+		if errors.Is(err, interaction.ErrInvalidCredentials) {
+			return nil, protocol.NewError("invalid_request", "invalid credentials")
+		} else if err != nil {
+			return nil, err
+		}
 
-		authnURI := h.AuthenticateEndpoint.AuthenticateEndpointURI()
-		authnURI = coreurl.WithQueryParamsAdded(authnURI, map[string]string{
-			"prompt": "login",
-		})
 		return authorizationResultRequireAuthn{
-			AuthenticateURI: authnURI,
-			AuthorizeURI:    h.AuthorizeEndpoint.AuthorizeEndpointURI(),
-			Request:         r2,
+			AuthenticateURI: authenticateURI,
 		}, nil
 	}
 
diff --git a/pkg/auth/dependency/oauth/handler/result_authz.go b/pkg/auth/dependency/oauth/handler/result_authz.go
index fe47ff6d..62fb651c 100644
--- a/pkg/auth/dependency/oauth/handler/result_authz.go
+++ b/pkg/auth/dependency/oauth/handler/result_authz.go
@@ -36,8 +36,6 @@ type (
 	}
 	authorizationResultRequireAuthn struct {
 		AuthenticateURI *url.URL
-		AuthorizeURI    *url.URL
-		Request         protocol.AuthorizationRequest
 	}
 )
 
@@ -76,18 +74,7 @@ func (a authorizationResultError) IsInternalError() bool {
 }
 
 func (a authorizationResultRequireAuthn) WriteResponse(rw http.ResponseWriter, r *http.Request) {
-	authorizeURI := coreurl.WithQueryParamsAdded(a.AuthorizeURI, a.Request)
-
-	q := map[string]string{
-		"redirect_uri": authorizeURI.String(),
-		"client_id":    a.Request.ClientID(),
-	}
-	if uiLocales, ok := a.Request["ui_locales"]; ok {
-		q["ui_locales"] = uiLocales
-	}
-	authenticateURI := coreurl.WithQueryParamsAdded(a.AuthenticateURI, q)
-
-	http.Redirect(rw, r, authenticateURI.String(), http.StatusFound)
+	http.Redirect(rw, r, a.AuthenticateURI.String(), http.StatusFound)
 }
 
 func (a authorizationResultRequireAuthn) IsInternalError() bool {
diff --git a/pkg/auth/dependency/oauth/handler/url_provider.go b/pkg/auth/dependency/oauth/handler/url_provider.go
new file mode 100644
index 00000000..ab5d1eb4
--- /dev/null
+++ b/pkg/auth/dependency/oauth/handler/url_provider.go
@@ -0,0 +1,20 @@
+package handler
+
+import (
+	"net/url"
+
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/protocol"
+	coreurl "github.com/skygeario/skygear-server/pkg/core/url"
+)
+
+type EndpointsProvider interface {
+	AuthorizeEndpointURI() *url.URL
+}
+
+type URLProvider struct {
+	Endpoints EndpointsProvider
+}
+
+func (p *URLProvider) AuthorizeURI(r protocol.AuthorizationRequest) *url.URL {
+	return coreurl.WithQueryParamsAdded(p.Endpoints.AuthorizeEndpointURI(), r)
+}
diff --git a/pkg/auth/dependency/oauth/metadata.go b/pkg/auth/dependency/oauth/metadata.go
index 0028e7d0..0e19c6ee 100644
--- a/pkg/auth/dependency/oauth/metadata.go
+++ b/pkg/auth/dependency/oauth/metadata.go
@@ -1,10 +1,9 @@
 package oauth
 
 type MetadataProvider struct {
-	AuthorizeEndpoint    AuthorizeEndpointProvider
-	TokenEndpoint        TokenEndpointProvider
-	RevokeEndpoint       RevokeEndpointProvider
-	AuthenticateEndpoint AuthenticateEndpointProvider
+	AuthorizeEndpoint AuthorizeEndpointProvider
+	TokenEndpoint     TokenEndpointProvider
+	RevokeEndpoint    RevokeEndpointProvider
 }
 
 func (p *MetadataProvider) PopulateMetadata(meta map[string]interface{}) {
diff --git a/pkg/auth/dependency/oauth/protocol/authz.go b/pkg/auth/dependency/oauth/protocol/authz.go
index edd0c427..9ccf0866 100644
--- a/pkg/auth/dependency/oauth/protocol/authz.go
+++ b/pkg/auth/dependency/oauth/protocol/authz.go
@@ -13,8 +13,10 @@ func (r AuthorizationRequest) RedirectURI() string  { return r["redirect_uri"] }
 func (r AuthorizationRequest) Scope() []string      { return parseSpaceDelimitedString(r["scope"]) }
 func (r AuthorizationRequest) Prompt() []string     { return parseSpaceDelimitedString(r["prompt"]) }
 func (r AuthorizationRequest) State() string        { return r["state"] }
+func (r AuthorizationRequest) LoginHint() string    { return r["login_hint"] }
 
-func (r AuthorizationRequest) SetPrompt(prompt []string) { r["prompt"] = strings.Join(prompt, " ") }
+func (r AuthorizationRequest) SetPrompt(prompt []string)     { r["prompt"] = strings.Join(prompt, " ") }
+func (r AuthorizationRequest) SetLoginHint(loginHint string) { r["login_hint"] = loginHint }
 
 func (r AuthorizationResponse) Code(v string)  { r["code"] = v }
 func (r AuthorizationResponse) State(v string) { r["state"] = v }
diff --git a/pkg/auth/dependency/oidc/handler/deps.go b/pkg/auth/dependency/oidc/handler/deps.go
index 87889dc2..af0869e8 100644
--- a/pkg/auth/dependency/oidc/handler/deps.go
+++ b/pkg/auth/dependency/oidc/handler/deps.go
@@ -2,7 +2,6 @@ package handler
 
 import (
 	"github.com/google/wire"
-	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oidc"
 	"github.com/skygeario/skygear-server/pkg/core/config"
 )
@@ -10,14 +9,14 @@ import (
 func ProvideEndSessionHandler(
 	cfg *config.TenantConfiguration,
 	endSession oidc.EndSessionEndpointProvider,
-	logout oauth.LogoutEndpointProvider,
-	settings oauth.SettingsEndpointProvider,
+	logout LogoutURLProvider,
+	settings SettingsURLProvider,
 ) *EndSessionHandler {
 	return &EndSessionHandler{
 		Clients:            cfg.AppConfig.Clients,
 		EndSessionEndpoint: endSession,
-		LogoutEndpoint:     logout,
-		SettingsEndpoint:   settings,
+		LogoutURL:          logout,
+		SettingsURL:        settings,
 	}
 }
 
diff --git a/pkg/auth/dependency/oidc/handler/handler_end_session.go b/pkg/auth/dependency/oidc/handler/handler_end_session.go
index a56f06c6..3022c481 100644
--- a/pkg/auth/dependency/oidc/handler/handler_end_session.go
+++ b/pkg/auth/dependency/oidc/handler/handler_end_session.go
@@ -5,7 +5,6 @@ import (
 	"net/url"
 
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/auth"
-	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oidc"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oidc/protocol"
 	"github.com/skygeario/skygear-server/pkg/core/config"
@@ -14,11 +13,19 @@ import (
 
 // TODO(oidc): write tests
 
+type LogoutURLProvider interface {
+	LogoutURI() *url.URL
+}
+
+type SettingsURLProvider interface {
+	SettingsURI() *url.URL
+}
+
 type EndSessionHandler struct {
 	Clients            []config.OAuthClientConfiguration
 	EndSessionEndpoint oidc.EndSessionEndpointProvider
-	LogoutEndpoint     oauth.LogoutEndpointProvider
-	SettingsEndpoint   oauth.SettingsEndpointProvider
+	LogoutURL          LogoutURLProvider
+	SettingsURL        SettingsURLProvider
 }
 
 func (h *EndSessionHandler) Handle(s auth.AuthSession, req protocol.EndSessionRequest, r *http.Request, rw http.ResponseWriter) error {
@@ -28,7 +35,7 @@ func (h *EndSessionHandler) Handle(s auth.AuthSession, req protocol.EndSessionRe
 			req,
 		)
 		logoutURI := coreurl.WithQueryParamsAdded(
-			h.LogoutEndpoint.LogoutEndpointURI(),
+			h.LogoutURL.LogoutURI(),
 			map[string]string{"redirect_uri": endSessionURI.String()},
 		)
 
@@ -43,7 +50,7 @@ func (h *EndSessionHandler) Handle(s auth.AuthSession, req protocol.EndSessionRe
 		if client != nil && client.ClientURI() != "" {
 			redirectURI = client.ClientURI()
 		} else {
-			redirectURI = h.SettingsEndpoint.SettingsEndpointURI().String()
+			redirectURI = h.SettingsURL.SettingsURI().String()
 		}
 		http.Redirect(rw, r, redirectURI, http.StatusFound)
 		return nil
diff --git a/pkg/auth/dependency/webapp/authenticate_provider_impl.go b/pkg/auth/dependency/webapp/authenticate_provider_impl.go
index e47f33af..57b935cc 100644
--- a/pkg/auth/dependency/webapp/authenticate_provider_impl.go
+++ b/pkg/auth/dependency/webapp/authenticate_provider_impl.go
@@ -22,11 +22,13 @@ import (
 type InteractionFlow interface {
 	LoginWithLoginID(loginID string) (*interactionflows.WebAppResult, error)
 	SignupWithLoginID(loginIDKey, loginID string) (*interactionflows.WebAppResult, error)
+	PromoteWithLoginID(loginIDKey, loginID string, userID string) (*interactionflows.WebAppResult, error)
 	EnterSecret(token string, secret string) (*interactionflows.WebAppResult, error)
 	TriggerOOBOTP(token string) (*interactionflows.WebAppResult, error)
 	LoginWithOAuthProvider(oauthAuthInfo sso.AuthInfo) (*interactionflows.WebAppResult, error)
 	LinkWithOAuthProvider(userID string, oauthAuthInfo sso.AuthInfo) (*interactionflows.WebAppResult, error)
 	UnlinkWithOAuthProvider(userID string, providerConfig config.OAuthProviderConfiguration) (*interactionflows.WebAppResult, error)
+	PromoteWithOAuthProvider(userID string, oauthAuthInfo sso.AuthInfo) (*interactionflows.WebAppResult, error)
 	AddLoginID(userID string, loginID loginid.LoginID) (*interactionflows.WebAppResult, error)
 	UpdateLoginID(userID string, oldLoginID loginid.LoginID, newLoginID loginid.LoginID) (*interactionflows.WebAppResult, error)
 	RemoveLoginID(userID string, loginID loginid.LoginID) (*interactionflows.WebAppResult, error)
@@ -290,6 +292,47 @@ func (p *AuthenticateProviderImpl) CreateLoginID(w http.ResponseWriter, r *http.
 	return
 }
 
+func (p *AuthenticateProviderImpl) GetPromoteLoginIDForm(w http.ResponseWriter, r *http.Request) (writeResponse func(err error), err error) {
+	return p.get(w, r, TemplateItemTypeAuthUIPromoteHTML)
+}
+
+func (p *AuthenticateProviderImpl) PromoteLoginID(w http.ResponseWriter, r *http.Request) (writeResponse func(err error), err error) {
+	var result *interactionflows.WebAppResult
+	writeResponse = func(err error) {
+		p.persistState(r, err)
+		p.handleResult(w, r, result, err)
+	}
+
+	state, err := p.restoreState(r)
+	if err != nil {
+		return
+	}
+
+	p.ValidateProvider.PrepareValues(r.Form)
+
+	err = p.ValidateProvider.Validate("#WebAppPromoteLoginIDRequest", r.Form)
+	if err != nil {
+		return
+	}
+
+	err = p.SetLoginID(r)
+	if err != nil {
+		return
+	}
+
+	result, err = p.Interactions.PromoteWithLoginID(
+		r.Form.Get("x_login_id_key"),
+		r.Form.Get("x_login_id"),
+		state.AnonymousUserID,
+	)
+	if err != nil {
+		return
+	}
+
+	r.Form["x_interaction_token"] = []string{result.Token}
+	return
+}
+
 func (p *AuthenticateProviderImpl) SetLoginID(r *http.Request) (err error) {
 	if r.Form.Get("x_login_id_input_type") == "phone" {
 		e164, e := phone.Parse(r.Form.Get("x_national_number"), r.Form.Get("x_calling_code"))
@@ -419,6 +462,57 @@ func (p *AuthenticateProviderImpl) LinkIdentityProvider(w http.ResponseWriter, r
 	return
 }
 
+func (p *AuthenticateProviderImpl) PromoteIdentityProvider(w http.ResponseWriter, r *http.Request, providerAlias string) (writeResponse func(err error), err error) {
+	var authURI string
+	writeResponse = func(err error) {
+		p.persistState(r, err)
+		if err != nil {
+			RedirectToCurrentPath(w, r)
+		} else {
+			http.Redirect(w, r, authURI, http.StatusFound)
+		}
+	}
+
+	oauthProvider := p.OAuthProviderFactory.NewOAuthProvider(providerAlias)
+	if oauthProvider == nil {
+		err = ErrOAuthProviderNotFound
+		return
+	}
+
+	webappState, err := p.restoreState(r)
+	if err != nil {
+		return
+	}
+
+	// create or update ui state
+	// state id will be set into the request query
+	p.persistState(r, nil)
+
+	// set hashed csrf cookies to sso state
+	// callback will verify if the request has the same cookie
+	cookie, err := r.Cookie(csrfCookieName)
+	if err != nil || cookie.Value == "" {
+		panic(errors.Newf("webapp: missing csrf cookies: %w", err))
+	}
+	hashedNonce := crypto.SHA256String(cookie.Value)
+	webappSSOState := SSOState{}
+	webappSSOState.SetRequestQuery(r.URL.Query().Encode())
+	state := sso.State{
+		Action: "promote",
+		LinkState: sso.LinkState{
+			UserID: webappState.AnonymousUserID,
+		},
+		HashedNonce: hashedNonce,
+		Extra:       webappSSOState,
+	}
+	encodedState, err := p.SSOProvider.EncodeState(state)
+	if err != nil {
+		return
+	}
+	authURI, err = oauthProvider.GetAuthURL(state, encodedState)
+	return
+}
+
 func (p *AuthenticateProviderImpl) UnlinkIdentityProvider(w http.ResponseWriter, r *http.Request, providerAlias string) (writeResponse func(err error), err error) {
 	var result *interactionflows.WebAppResult
 	writeResponse = func(err error) {
@@ -652,6 +746,8 @@ func (p *AuthenticateProviderImpl) HandleSSOCallback(w http.ResponseWriter, r *h
 		result, err = p.Interactions.LoginWithOAuthProvider(oauthAuthInfo)
 	case "link":
 		result, err = p.Interactions.LinkWithOAuthProvider(state.LinkState.UserID, oauthAuthInfo)
+	case "promote":
+		result, err = p.Interactions.PromoteWithOAuthProvider(state.LinkState.UserID, oauthAuthInfo)
 	}
 
 	if err != nil {
diff --git a/pkg/auth/dependency/webapp/deps.go b/pkg/auth/dependency/webapp/deps.go
index 84a72875..1c931c63 100644
--- a/pkg/auth/dependency/webapp/deps.go
+++ b/pkg/auth/dependency/webapp/deps.go
@@ -40,6 +40,7 @@ var DependencySet = wire.NewSet(
 	ProvideRenderProvider,
 	wire.Struct(new(StateStoreImpl), "*"),
 	wire.Bind(new(StateStore), new(*StateStoreImpl)),
+	wire.Struct(new(URLProvider), "*"),
 )
 
 func ProvideCSPMiddleware(tConfig *config.TenantConfiguration) mux.MiddlewareFunc {
diff --git a/pkg/auth/dependency/webapp/state.go b/pkg/auth/dependency/webapp/state.go
index 44572342..bc186d51 100644
--- a/pkg/auth/dependency/webapp/state.go
+++ b/pkg/auth/dependency/webapp/state.go
@@ -31,6 +31,8 @@ type State struct {
 	Form string `json:"form"`
 	// Error is either reset to nil or set to non-nil in every POST request.
 	Error *skyerr.APIError `json:"error"`
+	// AnonymousUserID is the ID of anonymous user during promotion flow.
+	AnonymousUserID string `json:"anonymous_user_id,omitempty"`
 }
 
 func NewState() *State {
diff --git a/pkg/auth/dependency/webapp/template.go b/pkg/auth/dependency/webapp/template.go
index d94ffb4d..26f03fcd 100644
--- a/pkg/auth/dependency/webapp/template.go
+++ b/pkg/auth/dependency/webapp/template.go
@@ -12,8 +12,9 @@ const (
 	TemplateItemTypeAuthUIFooterHTML   config.TemplateItemType = "auth_ui_footer.html"
 
 	// Interaction entrypoints
-	TemplateItemTypeAuthUILoginHTML  config.TemplateItemType = "auth_ui_login.html"
-	TemplateItemTypeAuthUISignupHTML config.TemplateItemType = "auth_ui_signup.html"
+	TemplateItemTypeAuthUILoginHTML   config.TemplateItemType = "auth_ui_login.html"
+	TemplateItemTypeAuthUISignupHTML  config.TemplateItemType = "auth_ui_signup.html"
+	TemplateItemTypeAuthUIPromoteHTML config.TemplateItemType = "auth_ui_promote.html"
 
 	// Interaction steps
 	// nolint: gosec
@@ -710,19 +711,19 @@ var TemplateAuthUISignupHTML = template.Spec{
 				{{ if eq .type "oauth" }}
 				<button class="btn sso-btn {{ .provider_type }}" type="submit" name="x_idp_id" value="{{ .provider_alias }}">
 					{{- if eq .provider_type "apple" -}}
-					{{ localize "sign-in-apple" }}
+					{{ localize "sign-up-apple" }}
 					{{- end -}}
 					{{- if eq .provider_type "google" -}}
-					{{ localize "sign-in-google" }}
+					{{ localize "sign-up-google" }}
 					{{- end -}}
 					{{- if eq .provider_type "facebook" -}}
-					{{ localize "sign-in-facebook" }}
+					{{ localize "sign-up-facebook" }}
 					{{- end -}}
 					{{- if eq .provider_type "linkedin" -}}
-					{{ localize "sign-in-linkedin" }}
+					{{ localize "sign-up-linkedin" }}
 					{{- end -}}
 					{{- if eq .provider_type "azureadv2" -}}
-					{{ localize "sign-in-azureadv2" }}
+					{{ localize "sign-up-azureadv2" }}
 					{{- end -}}
 				</button>
 				{{ end }}
@@ -803,6 +804,109 @@ var TemplateAuthUISignupHTML = template.Spec{
 `,
 }
 
+var TemplateAuthUIPromoteHTML = template.Spec{
+	Type:        TemplateItemTypeAuthUIPromoteHTML,
+	IsHTML:      true,
+	Translation: TemplateItemTypeAuthUITranslationJSON,
+	Defines:     defines,
+	Components:  components,
+	Default: `<!DOCTYPE html>
+<html>
+{{ template "auth_ui_html_head.html" . }}
+<body class="page">
+	<div class="content">
+		{{ template "auth_ui_header.html" . }}
+		<div class="authorize-form">
+			<form class="authorize-idp-form" method="post">
+				{{ $.csrfField }}
+				{{ range .x_identity_candidates }}
+				{{ if eq .type "oauth" }}
+				<button class="btn sso-btn {{ .provider_type }}" type="submit" name="x_idp_id" value="{{ .provider_alias }}">
+					{{- if eq .provider_type "apple" -}}
+					{{ localize "sign-up-apple" }}
+					{{- end -}}
+					{{- if eq .provider_type "google" -}}
+					{{ localize "sign-up-google" }}
+					{{- end -}}
+					{{- if eq .provider_type "facebook" -}}
+					{{ localize "sign-up-facebook" }}
+					{{- end -}}
+					{{- if eq .provider_type "linkedin" -}}
+					{{ localize "sign-up-linkedin" }}
+					{{- end -}}
+					{{- if eq .provider_type "azureadv2" -}}
+					{{ localize "sign-up-azureadv2" }}
+					{{- end -}}
+				</button>
+				{{ end }}
+				{{ end }}
+			</form>
+
+			{{ $has_oauth := false }}
+			{{ $has_login_id := false }}
+			{{ range .x_identity_candidates }}
+				{{ if eq .type "oauth" }}
+				{{ $has_oauth = true }}
+				{{ end }}
+				{{ if eq .type "login_id" }}
+				{{ $has_login_id = true }}
+				{{ end }}
+			{{ end }}
+			{{ if $has_oauth }}{{ if $has_login_id }}
+			<div class="primary-txt sso-loginid-separator">{{ localize "sso-login-id-separator" }}</div>
+			{{ end }}{{ end }}
+
+			{{ template "ERROR" . }}
+
+			<form class="authorize-loginid-form" method="post">
+				{{ $.csrfField }}
+				<input type="hidden" name="x_login_id_key" value="{{ .x_login_id_key }}">
+
+				{{ range .x_identity_candidates }}
+				{{ if eq .type "login_id" }}{{ if eq .login_id_key $.x_login_id_key }}
+				{{ if eq .login_id_type "phone" }}
+					<div class="phone-input">
+						<select class="input select primary-txt" name="x_calling_code">
+							{{ range $.x_calling_codes }}
+							<option
+								value="{{ . }}"
+								{{ if $.x_calling_code }}{{ if eq $.x_calling_code . }}
+								selected
+								{{ end }}{{ end }}
+								>
+								+{{ . }}
+							</option>
+							{{ end }}
+						</select>
+						<input class="input text-input primary-txt" type="tel" name="x_national_number" placeholder="{{ localize "phone-number-placeholder" }}" value="{{ $.x_national_number }}">
+					</div>
+				{{ else }}
+					<input class="input text-input primary-txt" type="text" name="x_login_id" placeholder="{{ .login_id_type }}" value="{{ $.x_login_id }}">
+				{{ end }}
+				{{ end }}{{ end }}
+				{{ end }}
+
+				{{ range .x_identity_candidates }}
+				{{ if eq .type "login_id" }}{{ if not (eq .login_id_key $.x_login_id_key) }}
+					<a class="link align-self-flex-start"
+						href="{{ call $.MakeURLWithQuery "x_login_id_key" .login_id_key "x_login_id_input_type" .login_id_input_type}}">
+						{{ localize "use-login-id-key" .login_id_key }}
+					</a>
+				{{ end }}{{ end }}
+				{{ end }}
+
+				<button class="btn primary-btn align-self-flex-end" type="submit" name="submit" value="">
+					{{ localize "next-button-label" }}
+				</button>
+			</form>
+		</div>
+		{{ template "auth_ui_footer.html" . }}
+	</div>
+</body>
+</html>
+`,
+}
+
 var TemplateAuthUICreatePasswordHTML = template.Spec{
 	Type:        TemplateItemTypeAuthUICreatePasswordHTML,
 	IsHTML:      true,
diff --git a/pkg/auth/dependency/webapp/translation.go b/pkg/auth/dependency/webapp/translation.go
index 8aca9f65..2f0ef45b 100644
--- a/pkg/auth/dependency/webapp/translation.go
+++ b/pkg/auth/dependency/webapp/translation.go
@@ -29,10 +29,15 @@ var TemplateAuthUITranslationJSON = template.Spec{
 	"change-button-label": "Change",
 
 	"sign-in-apple": "Sign in with Apple",
+	"sign-up-apple": "Sign up with Apple",
 	"sign-in-google": "Sign in with Google",
+	"sign-up-google": "Sign up with Google",
 	"sign-in-facebook": "Sign in with Facebook",
+	"sign-up-facebook": "Sign up with Facebook",
 	"sign-in-linkedin": "Sign in with LinkedIn",
+	"sign-up-linkedin": "Sign up with LinkedIn",
 	"sign-in-azureadv2": "Sign in with Azure AD",
+	"sign-up-azureadv2": "Sign up with Azure AD",
 	"sso-login-id-separator": "or",
 
 	"phone-number-placeholder": "Phone number",
diff --git a/pkg/auth/dependency/webapp/url_provider.go b/pkg/auth/dependency/webapp/url_provider.go
new file mode 100644
index 00000000..1d700cbb
--- /dev/null
+++ b/pkg/auth/dependency/webapp/url_provider.go
@@ -0,0 +1,105 @@
+package webapp
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity/anonymous"
+	coreurl "github.com/skygeario/skygear-server/pkg/core/url"
+)
+
+type EndpointsProvider interface {
+	AuthenticateEndpointURI() *url.URL
+	PromoteUserEndpointURI() *url.URL
+	LogoutEndpointURI() *url.URL
+	SettingsEndpointURI() *url.URL
+}
+
+type AnonymousFlow interface {
+	DecodeUserID(requestJWT string) (string, anonymous.RequestAction, error)
+}
+
+type AuthenticateURLOptions struct {
+	ClientID    string
+	RedirectURI string
+	UILocales   string
+	Prompt      string
+	LoginHint   string
+}
+
+type URLProvider struct {
+	Endpoints EndpointsProvider
+	Anonymous AnonymousFlow
+	States    StateStore
+}
+
+func (p *URLProvider) AuthenticateURI(options AuthenticateURLOptions) (*url.URL, error) {
+	authnURI := p.Endpoints.AuthenticateEndpointURI()
+	q := map[string]string{
+		"redirect_uri": options.RedirectURI,
+		"client_id":    options.ClientID,
+	}
+	if options.Prompt != "" {
+		q["prompt"] = options.Prompt
+	}
+	if options.UILocales != "" {
+		q["ui_locales"] = options.UILocales
+	}
+	if options.LoginHint != "" {
+		err := p.convertLoginHint(&authnURI, q, options.LoginHint)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return coreurl.WithQueryParamsAdded(authnURI, q), nil
+}
+
+func (p *URLProvider) LogoutURI() *url.URL {
+	return p.Endpoints.LogoutEndpointURI()
+}
+
+func (p *URLProvider) SettingsURI() *url.URL {
+	return p.Endpoints.SettingsEndpointURI()
+}
+
+func (p *URLProvider) convertLoginHint(uri **url.URL, q map[string]string, loginHint string) error {
+	if !strings.HasPrefix(loginHint, "https://auth.skygear.io/login_hint?") {
+		return nil
+	}
+
+	url, err := url.Parse(loginHint)
+	if err != nil {
+		return err
+	}
+	query := url.Query()
+
+	switch query.Get("type") {
+	case "anonymous":
+		userID, action, err := p.Anonymous.DecodeUserID(query.Get("jwt"))
+		if err != nil {
+			return err
+		}
+
+		switch action {
+		case anonymous.RequestActionPromote:
+			state := NewState()
+			state.AnonymousUserID = userID
+			p.States.Set(state)
+			q["x_sid"] = state.ID
+			*uri = p.Endpoints.PromoteUserEndpointURI()
+			return nil
+
+		case anonymous.RequestActionAuth:
+			// TODO(webapp): support anonymous auth
+			panic("webapp: anonymous auth through web app is not supported")
+
+		default:
+			return errors.New("unknown anonymous request action")
+		}
+
+	default:
+		return fmt.Errorf("unsupported login hint type: %s", query.Get("type"))
+	}
+}
diff --git a/pkg/auth/deps.go b/pkg/auth/deps.go
index 2408558b..c1398580 100644
--- a/pkg/auth/deps.go
+++ b/pkg/auth/deps.go
@@ -195,6 +195,7 @@ var interactionDependencySet = wire.NewSet(
 
 	wire.Bind(new(webapp.InteractionFlow), new(*interactionflows.WebAppFlow)),
 	wire.Bind(new(oauthhandler.AnonymousInteractionFlow), new(*interactionflows.AnonymousFlow)),
+	wire.Bind(new(webapp.AnonymousFlow), new(*interactionflows.AnonymousFlow)),
 
 	wire.Bind(new(hook.LoginIDProvider), new(*identityloginid.Provider)),
 
@@ -207,6 +208,27 @@ var challengeDependencySet = wire.NewSet(
 	wire.Bind(new(interactionflows.ChallengeProvider), new(*challenge.Provider)),
 )
 
+var endpointsDependencySet = wire.NewSet(
+	endpointsProviderSet,
+
+	wire.Bind(new(oauth.AuthorizeEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oauth.TokenEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oauth.RevokeEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oidc.JWKSEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oidc.UserInfoEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oidc.EndSessionEndpointProvider), new(*EndpointsProvider)),
+	wire.Bind(new(oauthhandler.EndpointsProvider), new(*EndpointsProvider)),
+	wire.Bind(new(webapp.EndpointsProvider), new(*EndpointsProvider)),
+)
+
+var webappDependencySet = wire.NewSet(
+	webapp.DependencySet,
+
+	wire.Bind(new(oauthhandler.AuthenticateURLProvider), new(*webapp.URLProvider)),
+	wire.Bind(new(oidchandler.LogoutURLProvider), new(*webapp.URLProvider)),
+	wire.Bind(new(oidchandler.SettingsURLProvider), new(*webapp.URLProvider)),
+)
+
 var CommonDependencySet = wire.NewSet(
 	ProvideTenantConfig,
 	ProvideSessionInsecureCookieConfig,
@@ -216,7 +238,7 @@ var CommonDependencySet = wire.NewSet(
 	ProvideTaskExecutor,
 	ProvideTemplateEngine,
 	ProvideStaticAssetURLPrefix,
-	endpointsProviderSet,
+	endpointsDependencySet,
 
 	ProvideAuthSQLBuilder,
 	ProvidePrincipalProviders,
@@ -250,7 +272,7 @@ var CommonDependencySet = wire.NewSet(
 	urlprefix.DependencySet,
 	mfa.DependencySet,
 	mfapq.DependencySet,
-	webapp.DependencySet,
+	webappDependencySet,
 	oauthhandler.DependencySet,
 	oauth.DependencySet,
 	oauthpq.DependencySet,
diff --git a/pkg/auth/endpoints.go b/pkg/auth/endpoints.go
index edee7295..da1e2c5d 100644
--- a/pkg/auth/endpoints.go
+++ b/pkg/auth/endpoints.go
@@ -5,8 +5,6 @@ import (
 	"path"
 
 	"github.com/google/wire"
-	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
-	"github.com/skygeario/skygear-server/pkg/auth/dependency/oidc"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/urlprefix"
 )
 
@@ -26,19 +24,11 @@ func (p *EndpointsProvider) RevokeEndpointURI() *url.URL       { return p.urlOf(
 func (p *EndpointsProvider) JWKSEndpointURI() *url.URL         { return p.urlOf("oauth2/jwks") }
 func (p *EndpointsProvider) UserInfoEndpointURI() *url.URL     { return p.urlOf("oauth2/userinfo") }
 func (p *EndpointsProvider) EndSessionEndpointURI() *url.URL   { return p.urlOf("oauth2/end_session") }
-func (p *EndpointsProvider) AuthenticateEndpointURI() *url.URL { return p.urlOf(".") }
+func (p *EndpointsProvider) AuthenticateEndpointURI() *url.URL { return p.urlOf("./login") }
+func (p *EndpointsProvider) PromoteUserEndpointURI() *url.URL  { return p.urlOf("./promote_user") }
 func (p *EndpointsProvider) LogoutEndpointURI() *url.URL       { return p.urlOf("./logout") }
 func (p *EndpointsProvider) SettingsEndpointURI() *url.URL     { return p.urlOf("./settings") }
 
 var endpointsProviderSet = wire.NewSet(
 	wire.Struct(new(EndpointsProvider), "*"),
-	wire.Bind(new(oauth.AuthorizeEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oauth.TokenEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oauth.RevokeEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oidc.JWKSEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oidc.UserInfoEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oidc.EndSessionEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oauth.AuthenticateEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oauth.LogoutEndpointProvider), new(*EndpointsProvider)),
-	wire.Bind(new(oauth.SettingsEndpointProvider), new(*EndpointsProvider)),
 )
diff --git a/pkg/auth/handler/oauth/wire_gen.go b/pkg/auth/handler/oauth/wire_gen.go
index 7f172268..9a816e9e 100644
--- a/pkg/auth/handler/oauth/wire_gen.go
+++ b/pkg/auth/handler/oauth/wire_gen.go
@@ -9,7 +9,7 @@ import (
 	"github.com/skygeario/skygear-server/pkg/auth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/audit"
 	auth2 "github.com/skygeario/skygear-server/pkg/auth/dependency/auth"
-	redis2 "github.com/skygeario/skygear-server/pkg/auth/dependency/auth/redis"
+	redis3 "github.com/skygeario/skygear-server/pkg/auth/dependency/auth/redis"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/authenticator/bearertoken"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/authenticator/oob"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/authenticator/password"
@@ -20,12 +20,12 @@ import (
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/hook"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity/anonymous"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity/loginid"
-	oauth2 "github.com/skygeario/skygear-server/pkg/auth/dependency/identity/oauth"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity/oauth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/identity/provider"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/interaction"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/interaction/flows"
-	redis4 "github.com/skygeario/skygear-server/pkg/auth/dependency/interaction/redis"
-	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
+	redis2 "github.com/skygeario/skygear-server/pkg/auth/dependency/interaction/redis"
+	oauth2 "github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/handler"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/pq"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/redis"
@@ -33,9 +33,10 @@ import (
 	handler2 "github.com/skygeario/skygear-server/pkg/auth/dependency/oidc/handler"
 	pq2 "github.com/skygeario/skygear-server/pkg/auth/dependency/passwordhistory/pq"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/session"
-	redis3 "github.com/skygeario/skygear-server/pkg/auth/dependency/session/redis"
+	redis4 "github.com/skygeario/skygear-server/pkg/auth/dependency/session/redis"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/urlprefix"
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/userprofile"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/webapp"
 	"github.com/skygeario/skygear-server/pkg/core/async"
 	pq3 "github.com/skygeario/skygear-server/pkg/core/auth/authinfo/pq"
 	"github.com/skygeario/skygear-server/pkg/core/config"
@@ -60,22 +61,67 @@ func newAuthorizeHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 		SQLBuilder:  sqlBuilder,
 		SQLExecutor: sqlExecutor,
 	}
-	provider := time.NewProvider()
-	grantStore := redis.ProvideGrantStore(context, factory, tenantConfiguration, sqlBuilder, sqlExecutor, provider)
+	timeProvider := time.NewProvider()
+	grantStore := redis.ProvideGrantStore(context, factory, tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider)
 	urlprefixProvider := urlprefix.NewProvider(r)
 	endpointsProvider := &auth.EndpointsProvider{
 		PrefixProvider: urlprefixProvider,
 	}
+	urlProvider := &handler.URLProvider{
+		Endpoints: endpointsProvider,
+	}
+	store := redis2.ProvideStore(context, tenantConfiguration, timeProvider)
+	reservedNameChecker := auth.ProvideReservedNameChecker(m)
+	loginidProvider := loginid.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration, reservedNameChecker)
+	oauthProvider := oauth.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider)
+	anonymousProvider := anonymous.ProvideProvider(sqlBuilder, sqlExecutor)
+	providerProvider := provider.ProvideProvider(tenantConfiguration, loginidProvider, oauthProvider, anonymousProvider)
+	passwordhistoryStore := pq2.ProvidePasswordHistoryStore(timeProvider, sqlBuilder, sqlExecutor)
+	passwordChecker := audit.ProvidePasswordChecker(tenantConfiguration, passwordhistoryStore)
+	passwordProvider := password.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, factory, passwordhistoryStore, passwordChecker, tenantConfiguration)
+	totpProvider := totp.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	engine := auth.ProvideTemplateEngine(tenantConfiguration, m)
+	executor := auth.ProvideTaskExecutor(m)
+	queue := async.ProvideTaskQueue(context, txContext, requestID, tenantConfiguration, executor)
+	oobProvider := oob.ProvideProvider(tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider, engine, urlprefixProvider, queue)
+	bearertokenProvider := bearertoken.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	recoverycodeProvider := recoverycode.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	provider3 := &provider2.Provider{
+		Password:     passwordProvider,
+		TOTP:         totpProvider,
+		OOBOTP:       oobProvider,
+		BearerToken:  bearertokenProvider,
+		RecoveryCode: recoverycodeProvider,
+	}
+	authinfoStore := pq3.ProvideStore(sqlBuilderFactory, sqlExecutor)
+	userprofileStore := userprofile.ProvideStore(timeProvider, sqlBuilder, sqlExecutor)
+	hookProvider := hook.ProvideHookProvider(context, sqlBuilder, sqlExecutor, requestID, tenantConfiguration, txContext, timeProvider, authinfoStore, userprofileStore, loginidProvider, factory)
+	userProvider := interaction.ProvideUserProvider(authinfoStore, userprofileStore, timeProvider, hookProvider, urlprefixProvider, queue, tenantConfiguration)
+	interactionProvider := interaction.ProvideProvider(store, timeProvider, factory, providerProvider, provider3, userProvider, oobProvider, tenantConfiguration, hookProvider)
+	provider4 := challenge.ProvideProvider(context, timeProvider, tenantConfiguration)
+	anonymousFlow := &flows.AnonymousFlow{
+		Interactions: interactionProvider,
+		Anonymous:    anonymousProvider,
+		Challenges:   provider4,
+	}
+	stateStoreImpl := &webapp.StateStoreImpl{
+		Context: context,
+	}
+	webappURLProvider := &webapp.URLProvider{
+		Endpoints: endpointsProvider,
+		Anonymous: anonymousFlow,
+		States:    stateStoreImpl,
+	}
 	scopesValidator := _wireScopesValidatorValue
 	tokenGenerator := _wireTokenGeneratorValue
-	authorizationHandler := handler.ProvideAuthorizationHandler(context, tenantConfiguration, factory, authorizationStore, grantStore, endpointsProvider, endpointsProvider, scopesValidator, tokenGenerator, provider)
+	authorizationHandler := handler.ProvideAuthorizationHandler(context, tenantConfiguration, factory, authorizationStore, grantStore, urlProvider, webappURLProvider, scopesValidator, tokenGenerator, timeProvider)
 	httpHandler := provideAuthorizeHandler(factory, txContext, authorizationHandler)
 	return httpHandler
 }
 
 var (
 	_wireScopesValidatorValue = handler.ScopesValidator(oidc.ValidateScopes)
-	_wireTokenGeneratorValue  = handler.TokenGenerator(oauth.GenerateToken)
+	_wireTokenGeneratorValue  = handler.TokenGenerator(oauth2.GenerateToken)
 )
 
 func newTokenHandler(r *http.Request, m auth.DependencyMap) http.Handler {
@@ -93,19 +139,19 @@ func newTokenHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 	}
 	timeProvider := time.NewProvider()
 	grantStore := redis.ProvideGrantStore(context, factory, tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider)
-	eventStore := redis2.ProvideEventStore(context, tenantConfiguration)
+	eventStore := redis3.ProvideEventStore(context, tenantConfiguration)
 	accessEventProvider := auth2.AccessEventProvider{
 		Store: eventStore,
 	}
-	store := redis3.ProvideStore(context, tenantConfiguration, timeProvider, factory)
+	store := redis4.ProvideStore(context, tenantConfiguration, timeProvider, factory)
 	authAccessEventProvider := &auth2.AccessEventProvider{
 		Store: eventStore,
 	}
 	sessionProvider := session.ProvideSessionProvider(r, store, authAccessEventProvider, tenantConfiguration)
-	redisStore := redis4.ProvideStore(context, tenantConfiguration, timeProvider)
+	redisStore := redis2.ProvideStore(context, tenantConfiguration, timeProvider)
 	reservedNameChecker := auth.ProvideReservedNameChecker(m)
 	loginidProvider := loginid.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration, reservedNameChecker)
-	oauthProvider := oauth2.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider)
+	oauthProvider := oauth.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider)
 	anonymousProvider := anonymous.ProvideProvider(sqlBuilder, sqlExecutor)
 	providerProvider := provider.ProvideProvider(tenantConfiguration, loginidProvider, oauthProvider, anonymousProvider)
 	passwordhistoryStore := pq2.ProvidePasswordHistoryStore(timeProvider, sqlBuilder, sqlExecutor)
@@ -168,11 +214,10 @@ func newMetadataHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 	endpointsProvider := &auth.EndpointsProvider{
 		PrefixProvider: urlprefixProvider,
 	}
-	metadataProvider := &oauth.MetadataProvider{
-		AuthorizeEndpoint:    endpointsProvider,
-		TokenEndpoint:        endpointsProvider,
-		RevokeEndpoint:       endpointsProvider,
-		AuthenticateEndpoint: endpointsProvider,
+	metadataProvider := &oauth2.MetadataProvider{
+		AuthorizeEndpoint: endpointsProvider,
+		TokenEndpoint:     endpointsProvider,
+		RevokeEndpoint:    endpointsProvider,
 	}
 	oidcMetadataProvider := &oidc.MetadataProvider{
 		URLPrefix:          urlprefixProvider,
@@ -219,7 +264,53 @@ func newEndSessionHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 	endpointsProvider := &auth.EndpointsProvider{
 		PrefixProvider: urlprefixProvider,
 	}
-	endSessionHandler := handler2.ProvideEndSessionHandler(tenantConfiguration, endpointsProvider, endpointsProvider, endpointsProvider)
+	timeProvider := time.NewProvider()
+	store := redis2.ProvideStore(context, tenantConfiguration, timeProvider)
+	sqlBuilderFactory := db.ProvideSQLBuilderFactory(tenantConfiguration)
+	sqlBuilder := auth.ProvideAuthSQLBuilder(sqlBuilderFactory)
+	sqlExecutor := db.ProvideSQLExecutor(context, tenantConfiguration)
+	reservedNameChecker := auth.ProvideReservedNameChecker(m)
+	loginidProvider := loginid.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration, reservedNameChecker)
+	oauthProvider := oauth.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider)
+	anonymousProvider := anonymous.ProvideProvider(sqlBuilder, sqlExecutor)
+	providerProvider := provider.ProvideProvider(tenantConfiguration, loginidProvider, oauthProvider, anonymousProvider)
+	passwordhistoryStore := pq2.ProvidePasswordHistoryStore(timeProvider, sqlBuilder, sqlExecutor)
+	passwordChecker := audit.ProvidePasswordChecker(tenantConfiguration, passwordhistoryStore)
+	passwordProvider := password.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, factory, passwordhistoryStore, passwordChecker, tenantConfiguration)
+	totpProvider := totp.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	engine := auth.ProvideTemplateEngine(tenantConfiguration, m)
+	executor := auth.ProvideTaskExecutor(m)
+	queue := async.ProvideTaskQueue(context, txContext, requestID, tenantConfiguration, executor)
+	oobProvider := oob.ProvideProvider(tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider, engine, urlprefixProvider, queue)
+	bearertokenProvider := bearertoken.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	recoverycodeProvider := recoverycode.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	provider3 := &provider2.Provider{
+		Password:     passwordProvider,
+		TOTP:         totpProvider,
+		OOBOTP:       oobProvider,
+		BearerToken:  bearertokenProvider,
+		RecoveryCode: recoverycodeProvider,
+	}
+	authinfoStore := pq3.ProvideStore(sqlBuilderFactory, sqlExecutor)
+	userprofileStore := userprofile.ProvideStore(timeProvider, sqlBuilder, sqlExecutor)
+	hookProvider := hook.ProvideHookProvider(context, sqlBuilder, sqlExecutor, requestID, tenantConfiguration, txContext, timeProvider, authinfoStore, userprofileStore, loginidProvider, factory)
+	userProvider := interaction.ProvideUserProvider(authinfoStore, userprofileStore, timeProvider, hookProvider, urlprefixProvider, queue, tenantConfiguration)
+	interactionProvider := interaction.ProvideProvider(store, timeProvider, factory, providerProvider, provider3, userProvider, oobProvider, tenantConfiguration, hookProvider)
+	provider4 := challenge.ProvideProvider(context, timeProvider, tenantConfiguration)
+	anonymousFlow := &flows.AnonymousFlow{
+		Interactions: interactionProvider,
+		Anonymous:    anonymousProvider,
+		Challenges:   provider4,
+	}
+	stateStoreImpl := &webapp.StateStoreImpl{
+		Context: context,
+	}
+	urlProvider := &webapp.URLProvider{
+		Endpoints: endpointsProvider,
+		Anonymous: anonymousFlow,
+		States:    stateStoreImpl,
+	}
+	endSessionHandler := handler2.ProvideEndSessionHandler(tenantConfiguration, endpointsProvider, urlProvider, urlProvider)
 	httpHandler := provideEndSessionHandler(factory, txContext, endSessionHandler)
 	return httpHandler
 }
@@ -267,7 +358,7 @@ func provideRevokeHandler(lf logging.Factory, tx db.TxContext, rh oauthRevokeHan
 	return h
 }
 
-func provideMetadataHandler(oauth3 *oauth.MetadataProvider, oidc2 *oidc.MetadataProvider) http.Handler {
+func provideMetadataHandler(oauth3 *oauth2.MetadataProvider, oidc2 *oidc.MetadataProvider) http.Handler {
 	h := &MetadataHandler{
 		metaProviders: []oauthMetadataProvider{oauth3, oidc2},
 	}
diff --git a/pkg/auth/handler/webapp/promote.go b/pkg/auth/handler/webapp/promote.go
new file mode 100644
index 00000000..8686a573
--- /dev/null
+++ b/pkg/auth/handler/webapp/promote.go
@@ -0,0 +1,61 @@
+package webapp
+
+import (
+	"net/http"
+
+	"github.com/gorilla/mux"
+
+	"github.com/skygeario/skygear-server/pkg/auth"
+	"github.com/skygeario/skygear-server/pkg/core/db"
+)
+
+func AttachPromoteHandler(
+	router *mux.Router,
+	authDependency auth.DependencyMap,
+) {
+	router.
+		NewRoute().
+		Path("/promote_user").
+		Methods("OPTIONS", "POST", "GET").
+		Handler(auth.MakeHandler(authDependency, newPromoteHandler))
+}
+
+type promoteProvider interface {
+	GetPromoteLoginIDForm(w http.ResponseWriter, r *http.Request) (func(error), error)
+	PromoteLoginID(w http.ResponseWriter, r *http.Request) (func(error), error)
+	PromoteIdentityProvider(w http.ResponseWriter, r *http.Request, providerAlias string) (func(error), error)
+}
+
+type PromoteHandler struct {
+	Provider  promoteProvider
+	TxContext db.TxContext
+}
+
+func (h *PromoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	db.WithTx(h.TxContext, func() error {
+		if r.Method == "GET" {
+			writeResponse, err := h.Provider.GetPromoteLoginIDForm(w, r)
+			writeResponse(err)
+			return err
+		}
+
+		if r.Method == "POST" {
+			if r.Form.Get("x_idp_id") != "" {
+				writeResponse, err := h.Provider.PromoteIdentityProvider(w, r, r.Form.Get("x_idp_id"))
+				writeResponse(err)
+				return err
+			}
+
+			writeResponse, err := h.Provider.PromoteLoginID(w, r)
+			writeResponse(err)
+			return err
+		}
+
+		return nil
+	})
+}
diff --git a/pkg/auth/handler/webapp/wire.go b/pkg/auth/handler/webapp/wire.go
index e88dc396..0d4133ac 100644
--- a/pkg/auth/handler/webapp/wire.go
+++ b/pkg/auth/handler/webapp/wire.go
@@ -100,6 +100,16 @@ func newSignupHandler(r *http.Request, m pkg.DependencyMap) http.Handler {
 	return nil
 }
 
+func newPromoteHandler(r *http.Request, m pkg.DependencyMap) http.Handler {
+	wire.Build(
+		dependencySet,
+		wire.Bind(new(promoteProvider), new(*webapp.AuthenticateProviderImpl)),
+		wire.Struct(new(PromoteHandler), "*"),
+		wire.Bind(new(http.Handler), new(*PromoteHandler)),
+	)
+	return nil
+}
+
 func newCreatePasswordHandler(r *http.Request, m pkg.DependencyMap) http.Handler {
 	wire.Build(
 		dependencySet,
diff --git a/pkg/auth/handler/webapp/wire_gen.go b/pkg/auth/handler/webapp/wire_gen.go
index 8064bb81..575685ca 100644
--- a/pkg/auth/handler/webapp/wire_gen.go
+++ b/pkg/auth/handler/webapp/wire_gen.go
@@ -700,6 +700,132 @@ func newSignupHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 	return signupHandler
 }
 
+func newPromoteHandler(r *http.Request, m auth.DependencyMap) http.Handler {
+	context := auth.ProvideContext(r)
+	tenantConfiguration := auth.ProvideTenantConfig(context, m)
+	validateProvider := webapp.ProvideValidateProvider(tenantConfiguration)
+	staticAssetURLPrefix := auth.ProvideStaticAssetURLPrefix(m)
+	engine := auth.ProvideTemplateEngine(tenantConfiguration, m)
+	timeProvider := time.NewProvider()
+	sqlBuilderFactory := db.ProvideSQLBuilderFactory(tenantConfiguration)
+	sqlBuilder := auth.ProvideAuthSQLBuilder(sqlBuilderFactory)
+	sqlExecutor := db.ProvideSQLExecutor(context, tenantConfiguration)
+	store := pq.ProvidePasswordHistoryStore(timeProvider, sqlBuilder, sqlExecutor)
+	passwordChecker := audit.ProvidePasswordChecker(tenantConfiguration, store)
+	reservedNameChecker := auth.ProvideReservedNameChecker(m)
+	loginidProvider := loginid.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration, reservedNameChecker)
+	oauthProvider := oauth.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider)
+	anonymousProvider := anonymous.ProvideProvider(sqlBuilder, sqlExecutor)
+	providerProvider := provider.ProvideProvider(tenantConfiguration, loginidProvider, oauthProvider, anonymousProvider)
+	renderProvider := webapp.ProvideRenderProvider(staticAssetURLPrefix, tenantConfiguration, engine, passwordChecker, providerProvider)
+	requestID := auth.ProvideLoggingRequestID(r)
+	factory := logging.ProvideLoggerFactory(context, requestID, tenantConfiguration)
+	passwordProvider := password.ProvidePasswordProvider(sqlBuilder, sqlExecutor, timeProvider, store, factory, tenantConfiguration, reservedNameChecker)
+	provider3 := oauth2.ProvideOAuthProvider(sqlBuilder, sqlExecutor)
+	v := auth.ProvidePrincipalProviders(provider3, passwordProvider)
+	identityProvider := principal.ProvideIdentityProvider(sqlBuilder, sqlExecutor, v)
+	authenticateProcess := authn.ProvideAuthenticateProcess(factory, timeProvider, passwordProvider, provider3, identityProvider)
+	loginIDChecker := loginid2.ProvideLoginIDChecker(tenantConfiguration, reservedNameChecker)
+	authinfoStore := pq2.ProvideStore(sqlBuilderFactory, sqlExecutor)
+	userprofileStore := userprofile.ProvideStore(timeProvider, sqlBuilder, sqlExecutor)
+	txContext := db.ProvideTxContext(context, tenantConfiguration)
+	hookProvider := hook.ProvideHookProvider(context, sqlBuilder, sqlExecutor, requestID, tenantConfiguration, txContext, timeProvider, authinfoStore, userprofileStore, loginidProvider, factory)
+	urlprefixProvider := urlprefix.NewProvider(r)
+	executor := auth.ProvideTaskExecutor(m)
+	queue := async.ProvideTaskQueue(context, txContext, requestID, tenantConfiguration, executor)
+	signupProcess := authn.ProvideSignupProcess(passwordChecker, loginIDChecker, identityProvider, passwordProvider, provider3, timeProvider, authinfoStore, userprofileStore, hookProvider, tenantConfiguration, urlprefixProvider, queue)
+	authorizationCodeStore := authn.ProvideAuthorizationCodeStore(context)
+	oAuthCoordinator := &authn.OAuthCoordinator{
+		Authn:                  authenticateProcess,
+		Signup:                 signupProcess,
+		AuthorizationCodeStore: authorizationCodeStore,
+	}
+	mfaStore := pq3.ProvideStore(tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider)
+	client := sms.ProvideSMSClient(context, tenantConfiguration)
+	sender := mail.ProvideMailSender(context, tenantConfiguration)
+	mfaSender := mfa.ProvideMFASender(tenantConfiguration, client, sender, engine)
+	mfaProvider := mfa.ProvideMFAProvider(mfaStore, tenantConfiguration, timeProvider, mfaSender)
+	sessionStore := redis.ProvideStore(context, tenantConfiguration, timeProvider, factory)
+	eventStore := redis2.ProvideEventStore(context, tenantConfiguration)
+	accessEventProvider := &auth2.AccessEventProvider{
+		Store: eventStore,
+	}
+	sessionProvider := session.ProvideSessionProvider(r, sessionStore, accessEventProvider, tenantConfiguration)
+	authorizationStore := &pq4.AuthorizationStore{
+		SQLBuilder:  sqlBuilder,
+		SQLExecutor: sqlExecutor,
+	}
+	grantStore := redis3.ProvideGrantStore(context, factory, tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider)
+	authAccessEventProvider := auth2.AccessEventProvider{
+		Store: eventStore,
+	}
+	redisStore := redis4.ProvideStore(context, tenantConfiguration, timeProvider)
+	provider4 := password2.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, factory, store, passwordChecker, tenantConfiguration)
+	totpProvider := totp.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	oobProvider := oob.ProvideProvider(tenantConfiguration, sqlBuilder, sqlExecutor, timeProvider, engine, urlprefixProvider, queue)
+	bearertokenProvider := bearertoken.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	recoverycodeProvider := recoverycode.ProvideProvider(sqlBuilder, sqlExecutor, timeProvider, tenantConfiguration)
+	provider5 := &provider2.Provider{
+		Password:     provider4,
+		TOTP:         totpProvider,
+		OOBOTP:       oobProvider,
+		BearerToken:  bearertokenProvider,
+		RecoveryCode: recoverycodeProvider,
+	}
+	userProvider := interaction.ProvideUserProvider(authinfoStore, userprofileStore, timeProvider, hookProvider, urlprefixProvider, queue, tenantConfiguration)
+	interactionProvider := interaction.ProvideProvider(redisStore, timeProvider, factory, providerProvider, provider5, userProvider, oobProvider, tenantConfiguration, hookProvider)
+	challengeProvider := challenge.ProvideProvider(context, timeProvider, tenantConfiguration)
+	anonymousFlow := &flows.AnonymousFlow{
+		Interactions: interactionProvider,
+		Anonymous:    anonymousProvider,
+		Challenges:   challengeProvider,
+	}
+	idTokenIssuer := oidc.ProvideIDTokenIssuer(tenantConfiguration, urlprefixProvider, authinfoStore, userprofileStore, timeProvider)
+	tokenGenerator := _wireTokenGeneratorValue
+	tokenHandler := handler.ProvideTokenHandler(r, tenantConfiguration, factory, authorizationStore, grantStore, grantStore, grantStore, authAccessEventProvider, sessionProvider, anonymousFlow, idTokenIssuer, tokenGenerator, timeProvider)
+	authnSessionProvider := authn.ProvideSessionProvider(mfaProvider, sessionProvider, tenantConfiguration, timeProvider, authinfoStore, userprofileStore, identityProvider, hookProvider, tokenHandler)
+	insecureCookieConfig := auth.ProvideSessionInsecureCookieConfig(m)
+	cookieConfiguration := session.ProvideSessionCookieConfiguration(r, insecureCookieConfig, tenantConfiguration)
+	mfaInsecureCookieConfig := auth.ProvideMFAInsecureCookieConfig(m)
+	bearerTokenCookieConfiguration := mfa.ProvideBearerTokenCookieConfiguration(r, mfaInsecureCookieConfig, tenantConfiguration)
+	providerFactory := &authn.ProviderFactory{
+		OAuth:                   oAuthCoordinator,
+		Authn:                   authenticateProcess,
+		Signup:                  signupProcess,
+		AuthnSession:            authnSessionProvider,
+		Session:                 sessionProvider,
+		SessionCookieConfig:     cookieConfiguration,
+		BearerTokenCookieConfig: bearerTokenCookieConfiguration,
+	}
+	authnProvider := authn.ProvideAuthUIProvider(providerFactory)
+	stateStoreImpl := &webapp.StateStoreImpl{
+		Context: context,
+	}
+	provider6 := sso.ProvideSSOProvider(context, tenantConfiguration)
+	userController := flows.ProvideUserController(authinfoStore, userprofileStore, tokenHandler, cookieConfiguration, sessionProvider, hookProvider, timeProvider, tenantConfiguration)
+	webAppFlow := &flows.WebAppFlow{
+		Interactions:   interactionProvider,
+		UserController: userController,
+	}
+	loginIDNormalizerFactory := loginid2.ProvideLoginIDNormalizerFactory(tenantConfiguration)
+	redirectURLFunc := provideRedirectURIForWebAppFunc()
+	oAuthProviderFactory := sso.ProvideOAuthProviderFactory(tenantConfiguration, urlprefixProvider, timeProvider, loginIDNormalizerFactory, redirectURLFunc)
+	authenticateProviderImpl := &webapp.AuthenticateProviderImpl{
+		ValidateProvider:     validateProvider,
+		RenderProvider:       renderProvider,
+		AuthnProvider:        authnProvider,
+		StateStore:           stateStoreImpl,
+		SSOProvider:          provider6,
+		Interactions:         webAppFlow,
+		OAuthProviderFactory: oAuthProviderFactory,
+	}
+	promoteHandler := &PromoteHandler{
+		Provider:  authenticateProviderImpl,
+		TxContext: txContext,
+	}
+	return promoteHandler
+}
+
 func newCreatePasswordHandler(r *http.Request, m auth.DependencyMap) http.Handler {
 	context := auth.ProvideContext(r)
 	tenantConfiguration := auth.ProvideTenantConfig(context, m)
diff --git a/pkg/auth/template/template.go b/pkg/auth/template/template.go
index 9248391c..c305d331 100644
--- a/pkg/auth/template/template.go
+++ b/pkg/auth/template/template.go
@@ -54,6 +54,7 @@ func NewEngineWithConfig(
 
 	e.Register(webapp.TemplateAuthUILoginHTML)
 	e.Register(webapp.TemplateAuthUISignupHTML)
+	e.Register(webapp.TemplateAuthUIPromoteHTML)
 
 	e.Register(webapp.TemplateAuthUIEnterPasswordHTML)
 	e.Register(webapp.TemplateAuthUICreatePasswordHTML)
@@ -64,7 +65,6 @@ func NewEngineWithConfig(
 	e.Register(webapp.TemplateAuthUIForgotPasswordSuccessHTML)
 	e.Register(webapp.TemplateAuthUIResetPasswordHTML)
 	e.Register(webapp.TemplateAuthUIResetPasswordSuccessHTML)
-
 	e.Register(webapp.TemplateAuthUILogoutHTML)
 
 	e.Register(webapp.TemplateAuthUISettingsHTML)


Test patch:
diff --git a/pkg/auth/dependency/oauth/handler/handler_authz_test.go b/pkg/auth/dependency/oauth/handler/handler_authz_test.go
index 51f43aee..fec1db8d 100644
--- a/pkg/auth/dependency/oauth/handler/handler_authz_test.go
+++ b/pkg/auth/dependency/oauth/handler/handler_authz_test.go
@@ -29,13 +29,13 @@ func TestAuthorizationHandler(t *testing.T) {
 			AppID:   "app-id",
 			Clients: []config.OAuthClientConfiguration{},
 
-			Authorizations:       authzStore,
-			CodeGrants:           codeGrantStore,
-			AuthorizeEndpoint:    mockEndpointsProvider{},
-			AuthenticateEndpoint: mockEndpointsProvider{},
-			ValidateScopes:       func(config.OAuthClientConfiguration, []string) error { return nil },
-			CodeGenerator:        func() string { return "authz-code" },
-			Time:                 mockTime,
+			Authorizations:  authzStore,
+			CodeGrants:      codeGrantStore,
+			AuthorizeURL:    mockEndpointsProvider{},
+			AuthenticateURL: mockEndpointsProvider{},
+			ValidateScopes:  func(config.OAuthClientConfiguration, []string) error { return nil },
+			CodeGenerator:   func() string { return "authz-code" },
+			Time:            mockTime,
 		}
 		handle := func(r protocol.AuthorizationRequest) *httptest.ResponseRecorder {
 			result := h.Handle(r)
@@ -161,8 +161,7 @@ func TestAuthorizationHandler(t *testing.T) {
 					"ui_locales":            "ja",
 				})
 				So(resp.Result().StatusCode, ShouldEqual, 302)
-				So(redirection(resp), ShouldEqual,
-					"https://auth/authenticate?client_id=client-id&redirect_uri=https%3A%2F%2Fauth%2Fauthorize%3Fclient_id%3Dclient-id%26code_challenge%3DE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM%26code_challenge_method%3DS256%26response_type%3Dcode%26scope%3Dopenid%26ui_locales%3Dja&ui_locales=ja")
+				So(redirection(resp), ShouldEqual, "https://auth/authenticate")
 			})
 			Convey("return authorization code", func() {
 				h.Context = authtesting.WithAuthn().
@@ -302,8 +301,7 @@ func TestAuthorizationHandler(t *testing.T) {
 					"scope":         "openid",
 				})
 				So(resp.Result().StatusCode, ShouldEqual, 302)
-				So(redirection(resp), ShouldEqual,
-					"https://auth/authenticate?client_id=client-id&redirect_uri=https%3A%2F%2Fauth%2Fauthorize%3Fclient_id%3Dclient-id%26response_type%3Dnone%26scope%3Dopenid")
+				So(redirection(resp), ShouldEqual, "https://auth/authenticate")
 			})
 			Convey("redirect to URI", func() {
 				h.Context = authtesting.WithAuthn().
diff --git a/pkg/auth/dependency/oauth/handler/mock_test.go b/pkg/auth/dependency/oauth/handler/mock_test.go
index e5b96623..e414ca2c 100644
--- a/pkg/auth/dependency/oauth/handler/mock_test.go
+++ b/pkg/auth/dependency/oauth/handler/mock_test.go
@@ -4,23 +4,20 @@ import (
 	"net/url"
 
 	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/oauth/protocol"
+	"github.com/skygeario/skygear-server/pkg/auth/dependency/webapp"
 )
 
 type mockEndpointsProvider struct{}
 
-func (mockEndpointsProvider) AuthorizeEndpointURI() *url.URL {
+func (mockEndpointsProvider) AuthorizeURI(r protocol.AuthorizationRequest) *url.URL {
 	u, _ := url.Parse("https://auth/authorize")
 	return u
 }
 
-func (mockEndpointsProvider) TokenEndpointURI() *url.URL {
-	u, _ := url.Parse("https://auth/token")
-	return u
-}
-
-func (mockEndpointsProvider) AuthenticateEndpointURI() *url.URL {
+func (mockEndpointsProvider) AuthenticateURI(opts webapp.AuthenticateURLOptions) (*url.URL, error) {
 	u, _ := url.Parse("https://auth/authenticate")
-	return u
+	return u, nil
 }
 
 type mockAuthzStore struct {