fix(auth): require explicit choice for third-party signup

This commit is contained in:
IanShaw027
2026-04-21 20:36:58 +08:00
parent 2cebb0dc60
commit 4c21320d1b
8 changed files with 638 additions and 296 deletions

View File

@@ -325,80 +325,18 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return return
} }
if compatEmailUser != nil { if err := h.createLinuxDoOAuthChoicePendingSession(
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ c,
Intent: "adopt_existing_user_by_email", identityKey,
Identity: identityKey, email,
TargetUserID: &compatEmailUser.ID, email,
ResolvedEmail: compatEmailUser.Email, redirectTo,
RedirectTo: redirectTo, browserSessionKey,
BrowserSessionKey: browserSessionKey, upstreamClaims,
UpstreamIdentityClaims: upstreamClaims, compatEmail,
CompletionResponse: map[string]any{ compatEmailUser,
"redirect": redirectTo, h.isForceEmailOnThirdPartySignup(c.Request.Context()),
"step": "bind_login_required", ); err != nil {
"email": compatEmailUser.Email,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
if err != nil {
if errors.Is(err, service.ErrOAuthInvitationRequired) {
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: "login",
Identity: identityKey,
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{
"error": "invitation_required",
"redirect": redirectTo,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
return
}
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: "login",
Identity: identityKey,
TargetUserID: &user.ID,
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{
"access_token": tokenPair.AccessToken,
"refresh_token": tokenPair.RefreshToken,
"expires_in": tokenPair.ExpiresIn,
"token_type": "Bearer",
"redirect": redirectTo,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return return
} }
@@ -431,6 +369,62 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri
return userEntity, nil return userEntity, nil
} }
func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
c *gin.Context,
identity service.PendingAuthIdentityKey,
suggestedEmail string,
resolvedEmail string,
redirectTo string,
browserSessionKey string,
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
canonicalEmail := strings.TrimSpace(resolvedEmail)
if suggestionEmail == "" {
suggestionEmail = canonicalEmail
}
completionResponse := map[string]any{
"step": oauthPendingChoiceStep,
"adoption_required": true,
"redirect": strings.TrimSpace(redirectTo),
"email": suggestionEmail,
"resolved_email": canonicalEmail,
"existing_account_email": "",
"existing_account_bindable": false,
"create_account_allowed": true,
"force_email_on_signup": forceEmailOnSignup,
"choice_reason": "third_party_signup",
}
if strings.TrimSpace(compatEmail) != "" {
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
}
resolvedChoiceEmail := suggestionEmail
if compatEmailUser != nil {
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_bindable"] = true
completionResponse["choice_reason"] = "compat_email_match"
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
}
if forceEmailOnSignup && compatEmailUser == nil {
completionResponse["choice_reason"] = "force_email_on_signup"
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin,
Identity: identity,
ResolvedEmail: resolvedChoiceEmail,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
type completeLinuxDoOAuthRequest struct { type completeLinuxDoOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"` InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`

View File

@@ -32,6 +32,7 @@ const (
oauthPendingSessionCookiePath = "/api/v1/auth/oauth" oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
oauthPendingSessionCookieName = "oauth_pending_session" oauthPendingSessionCookieName = "oauth_pending_session"
oauthPendingCookieMaxAgeSec = 10 * 60 oauthPendingCookieMaxAgeSec = 10 * 60
oauthPendingChoiceStep = "choose_account_action_required"
oauthCompletionResponseKey = "completion_response" oauthCompletionResponseKey = "completion_response"
) )
@@ -431,8 +432,9 @@ func (h *AuthHandler) createOAuthEmailRequiredPendingSession(
BrowserSessionKey: browserSessionKey, BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims, UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{ CompletionResponse: map[string]any{
"redirect": redirectTo, "redirect": strings.TrimSpace(redirectTo),
"step": "email_required", "step": oauthPendingChoiceStep,
"adoption_required": true,
"force_email_on_signup": true, "force_email_on_signup": true,
"email_binding_required": true, "email_binding_required": true,
"existing_account_bindable": true, "existing_account_bindable": true,
@@ -492,7 +494,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
email := strings.TrimSpace(strings.ToLower(req.Email)) email := strings.TrimSpace(strings.ToLower(req.Email))
if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil { if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, oauthAdoptionDecisionRequest{}) session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -1206,12 +1208,13 @@ func readPendingOAuthBrowserSession(c *gin.Context, h *AuthHandler) (*service.Au
} }
func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H { func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H {
completionResponse := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, nil))
payload := gin.H{ payload := gin.H{
"auth_result": "pending_session", "auth_result": "pending_session",
"provider": strings.TrimSpace(session.ProviderType), "provider": strings.TrimSpace(session.ProviderType),
"intent": strings.TrimSpace(session.Intent), "intent": strings.TrimSpace(session.Intent),
} }
for key, value := range mergePendingCompletionResponse(session, nil) { for key, value := range completionResponse {
payload[key] = value payload[key] = value
} }
if email := strings.TrimSpace(session.ResolvedEmail); email != "" { if email := strings.TrimSpace(session.ResolvedEmail); email != "" {
@@ -1220,38 +1223,58 @@ func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gi
return payload return payload
} }
func (h *AuthHandler) transitionPendingOAuthAccountToBindLogin( func normalizePendingOAuthCompletionResponse(payload map[string]any) map[string]any {
normalized := clonePendingMap(payload)
step := strings.ToLower(strings.TrimSpace(pendingSessionStringValue(normalized, "step")))
switch step {
case "choice", "choose_account_action", "choose_account", "choose", "email_required", "bind_login_required":
normalized["step"] = oauthPendingChoiceStep
}
if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(normalized, "step")), oauthPendingChoiceStep) {
normalized["adoption_required"] = true
}
if _, exists := normalized["adoption_required"]; !exists {
if _, hasChoiceFields := normalized["email_binding_required"]; hasChoiceFields {
normalized["adoption_required"] = true
}
}
return normalized
}
func pendingOAuthChoiceCompletionResponse(session *dbent.PendingAuthSession, email string) map[string]any {
response := mergePendingCompletionResponse(session, map[string]any{
"step": oauthPendingChoiceStep,
"adoption_required": true,
"force_email_on_signup": true,
"email_binding_required": true,
"existing_account_bindable": true,
})
if email = strings.TrimSpace(email); email != "" {
response["email"] = email
response["resolved_email"] = email
}
return response
}
func (h *AuthHandler) transitionPendingOAuthAccountToChoiceState(
c *gin.Context, c *gin.Context,
client *dbent.Client, client *dbent.Client,
session *dbent.PendingAuthSession, session *dbent.PendingAuthSession,
email string, email string,
decision oauthAdoptionDecisionRequest,
) (*dbent.PendingAuthSession, error) { ) (*dbent.PendingAuthSession, error) {
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email) completionResponse := pendingOAuthChoiceCompletionResponse(session, email)
if err != nil { session, err := updatePendingOAuthSessionProgress(
return nil, err
}
completionResponse := mergePendingCompletionResponse(session, map[string]any{
"step": "bind_login_required",
"email": email,
})
session, err = updatePendingOAuthSessionProgress(
c.Request.Context(), c.Request.Context(),
client, client,
session, session,
"adopt_existing_user_by_email", strings.TrimSpace(session.Intent),
email, email,
&existingUser.ID, nil,
completionResponse, completionResponse,
) )
if err != nil { if err != nil {
return nil, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err) return nil, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err)
} }
if _, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, decision); err != nil {
return nil, err
}
return session, nil return session, nil
} }
@@ -1365,12 +1388,20 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
email := strings.TrimSpace(strings.ToLower(req.Email)) email := strings.TrimSpace(strings.ToLower(req.Email))
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email) existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
if err != nil && !errors.Is(err, service.ErrUserNotFound) { if err != nil {
response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")) switch {
return case errors.Is(err, service.ErrUserNotFound):
existingUser = nil
case infraerrors.Code(err) >= http.StatusBadRequest && infraerrors.Code(err) < http.StatusInternalServerError:
response.ErrorFrom(c, err)
return
default:
response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable"))
return
}
} }
if existingUser != nil { if existingUser != nil {
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, req.adoptionDecision()) session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -1393,7 +1424,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
) )
if err != nil { if err != nil {
if errors.Is(err, service.ErrEmailExists) { if errors.Is(err, service.ErrEmailExists) {
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, req.adoptionDecision()) session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -1548,6 +1579,7 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid")) response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid"))
return return
} }
payload = normalizePendingOAuthCompletionResponse(payload)
if strings.TrimSpace(session.RedirectTo) != "" { if strings.TrimSpace(session.RedirectTo) != "" {
if _, exists := payload["redirect"]; !exists { if _, exists := payload["redirect"]; !exists {
payload["redirect"] = session.RedirectTo payload["redirect"] = session.RedirectTo

View File

@@ -420,27 +420,6 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return return
} }
if compatEmailUser != nil {
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: "adopt_existing_user_by_email",
Identity: identityRef,
TargetUserID: &compatEmailUser.ID,
ResolvedEmail: compatEmailUser.Email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{
"redirect": redirectTo,
"step": "bind_login_required",
"email": compatEmailUser.Email,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
if cfg.RequireEmailVerified { if cfg.RequireEmailVerified {
if emailVerified == nil || !*emailVerified { if emailVerified == nil || !*emailVerified {
@@ -450,7 +429,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
} }
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) { if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
if err := h.createOAuthEmailRequiredPendingSession(c, identityRef, redirectTo, browserSessionKey, upstreamClaims); err != nil { if err := h.createOIDCOAuthChoicePendingSession(
c,
identityRef,
email,
email,
redirectTo,
browserSessionKey,
upstreamClaims,
compatEmail,
compatEmailUser,
true,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return return
} }
@@ -458,48 +448,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
return return
} }
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired if err := h.createOIDCOAuthChoicePendingSession(
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "") c,
if err != nil { identityRef,
if errors.Is(err, service.ErrOAuthInvitationRequired) { email,
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ email,
Intent: "login", redirectTo,
Identity: identityRef, browserSessionKey,
ResolvedEmail: email, upstreamClaims,
RedirectTo: redirectTo, compatEmail,
BrowserSessionKey: browserSessionKey, compatEmailUser,
UpstreamIdentityClaims: upstreamClaims, h.isForceEmailOnThirdPartySignup(c.Request.Context()),
CompletionResponse: map[string]any{ ); err != nil {
"error": "invitation_required",
"redirect": redirectTo,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
return
}
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: "login",
Identity: identityRef,
TargetUserID: &user.ID,
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{
"access_token": tokenPair.AccessToken,
"refresh_token": tokenPair.RefreshToken,
"expires_in": tokenPair.ExpiresIn,
"token_type": "Bearer",
"redirect": redirectTo,
},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return return
} }
@@ -530,6 +490,65 @@ func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string)
return userEntity, nil return userEntity, nil
} }
func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
c *gin.Context,
identity service.PendingAuthIdentityKey,
suggestedEmail string,
resolvedEmail string,
redirectTo string,
browserSessionKey string,
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
canonicalEmail := strings.TrimSpace(resolvedEmail)
if suggestionEmail == "" {
suggestionEmail = canonicalEmail
}
completionResponse := map[string]any{
"step": oauthPendingChoiceStep,
"adoption_required": true,
"redirect": strings.TrimSpace(redirectTo),
"email": suggestionEmail,
"resolved_email": canonicalEmail,
"existing_account_email": "",
"existing_account_bindable": false,
"create_account_allowed": true,
"force_email_on_signup": forceEmailOnSignup,
"choice_reason": "third_party_signup",
}
if strings.TrimSpace(compatEmail) != "" {
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
}
if compatEmailUser != nil {
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_bindable"] = true
completionResponse["choice_reason"] = "compat_email_match"
}
if forceEmailOnSignup && compatEmailUser == nil {
completionResponse["choice_reason"] = "force_email_on_signup"
}
resolvedChoiceEmail := suggestionEmail
if compatEmailUser != nil {
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin,
Identity: identity,
ResolvedEmail: resolvedChoiceEmail,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
type completeOIDCOAuthRequest struct { type completeOIDCOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"` InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`

View File

@@ -62,6 +62,8 @@ type wechatOAuthConfig struct {
scope string scope string
redirectURI string redirectURI string
frontendCallback string frontendCallback string
openEnabled bool
mpEnabled bool
} }
type wechatOAuthTokenResponse struct { type wechatOAuthTokenResponse struct {
@@ -209,11 +211,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID)) unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID)) openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
if unionid == "" { providerSubject := unionid
if providerSubject == "" {
if cfg.requiresUnionID() {
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
return
}
providerSubject = openid
}
if providerSubject == "" {
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "") redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
return return
} }
providerSubject := unionid
username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject)) username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
email := wechatSyntheticEmail(providerSubject) email := wechatSyntheticEmail(providerSubject)
@@ -284,7 +293,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
} }
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) { if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
if err := h.createOAuthEmailRequiredPendingSession(c, identityRef, redirectTo, browserSessionKey, upstreamClaims); err != nil { if err := h.createWeChatChoicePendingSession(
c,
identityRef,
email,
email,
redirectTo,
browserSessionKey,
upstreamClaims,
"",
nil,
true,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return return
} }
@@ -292,17 +312,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
return return
} }
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "") if err := h.createWeChatChoicePendingSession(
if err != nil { c,
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err, nil); err != nil { identityRef,
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") email,
return email,
} redirectTo,
redirectToFrontendCallback(c, frontendCallback) browserSessionKey,
return upstreamClaims,
} "",
nil,
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, nil); err != nil { false,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return return
} }
@@ -600,6 +621,65 @@ func (h *AuthHandler) createWeChatPendingSession(
}) })
} }
func (h *AuthHandler) createWeChatChoicePendingSession(
c *gin.Context,
identity service.PendingAuthIdentityKey,
suggestedEmail string,
resolvedEmail string,
redirectTo string,
browserSessionKey string,
upstreamClaims map[string]any,
compatEmail string,
compatEmailUser *dbent.User,
forceEmailOnSignup bool,
) error {
suggestionEmail := strings.TrimSpace(suggestedEmail)
canonicalEmail := strings.TrimSpace(resolvedEmail)
if suggestionEmail == "" {
suggestionEmail = canonicalEmail
}
completionResponse := map[string]any{
"step": oauthPendingChoiceStep,
"adoption_required": true,
"redirect": strings.TrimSpace(redirectTo),
"email": suggestionEmail,
"resolved_email": canonicalEmail,
"existing_account_email": "",
"existing_account_bindable": false,
"create_account_allowed": true,
"force_email_on_signup": forceEmailOnSignup,
"choice_reason": "third_party_signup",
}
if strings.TrimSpace(compatEmail) != "" {
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
}
if compatEmailUser != nil {
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
completionResponse["existing_account_bindable"] = true
completionResponse["choice_reason"] = "compat_email_match"
}
if forceEmailOnSignup {
completionResponse["choice_reason"] = "force_email_on_signup"
}
resolvedChoiceEmail := suggestionEmail
if compatEmailUser != nil {
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin,
Identity: identity,
ResolvedEmail: resolvedChoiceEmail,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
func (h *AuthHandler) createWeChatBindPendingSession( func (h *AuthHandler) createWeChatBindPendingSession(
c *gin.Context, c *gin.Context,
cfg wechatOAuthConfig, cfg wechatOAuthConfig,
@@ -874,7 +954,7 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
if err != nil { if err != nil {
return wechatOAuthConfig{}, err return wechatOAuthConfig{}, err
} }
if effective.Mode != mode { if !effective.SupportsMode(mode) {
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
} }
@@ -884,7 +964,9 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
appSecret: strings.TrimSpace(effective.AppSecret), appSecret: strings.TrimSpace(effective.AppSecret),
redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")), redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")),
frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB), frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB),
scope: firstNonEmpty(strings.TrimSpace(effective.Scopes), service.DefaultWeChatConnectScopesForMode(mode)), scope: effective.ScopeForMode(mode),
openEnabled: effective.OpenEnabled,
mpEnabled: effective.MPEnabled,
} }
switch mode { switch mode {
@@ -900,6 +982,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
return cfg, nil return cfg, nil
} }
func (cfg wechatOAuthConfig) requiresUnionID() bool {
return cfg.openEnabled && cfg.mpEnabled
}
func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string { func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string {
if h != nil && h.settingSvc != nil { if h != nil && h.settingSvc != nil {
cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx) cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)

View File

@@ -65,6 +65,36 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) {
require.NotEmpty(t, findCookie(cookies, oauthPendingBrowserCookieName)) require.NotEmpty(t, findCookie(cookies, oauthPendingBrowserCookieName))
} }
func TestWeChatOAuthStart_AllowsOpenModeWhenBothCapabilitiesEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, map[string]string{
service.SettingKeyWeChatConnectEnabled: "true",
service.SettingKeyWeChatConnectAppID: "wx-shared-app",
service.SettingKeyWeChatConnectAppSecret: "wx-shared-secret",
service.SettingKeyWeChatConnectMode: "mp",
service.SettingKeyWeChatConnectScopes: "snsapi_base",
service.SettingKeyWeChatConnectOpenEnabled: "true",
service.SettingKeyWeChatConnectMPEnabled: "true",
service.SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
service.SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
})
defer client.Close()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil)
c.Request.Host = "api.example.com"
handler.WeChatOAuthStart(c)
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.NotEmpty(t, location)
require.Contains(t, location, "open.weixin.qq.com")
require.Contains(t, location, "connect/qrconnect")
require.Contains(t, location, "scope=snsapi_login")
}
func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) { func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL

View File

@@ -15,6 +15,7 @@
v-if=" v-if="
needsInvitation || needsInvitation ||
needsAdoptionConfirmation || needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount || needsCreateAccount ||
needsBindLogin || needsBindLogin ||
needsTotpChallenge needsTotpChallenge
@@ -109,6 +110,42 @@
</button> </button>
</template> </template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? `Suggested email: ${pendingAccountEmail}`
: 'Choose whether to bind an existing account or create a new one.'
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
</button>
</div>
</div>
</div>
</template>
<template v-else-if="needsCreateAccount"> <template v-else-if="needsCreateAccount">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Enter an email address to create your account and continue. Enter an email address to create your account and continue.
@@ -275,7 +312,7 @@ const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true) const adoptDisplayName = ref(true)
const adoptAvatar = ref(true) const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false) const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none') const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('') const pendingAccountEmail = ref('')
const bindLoginEmail = ref('') const bindLoginEmail = ref('')
const bindLoginPassword = ref('') const bindLoginPassword = ref('')
@@ -290,12 +327,17 @@ const totpError = ref('')
const totpUserEmailMasked = ref('') const totpUserEmailMasked = ref('')
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account') const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login') const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & { type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
step?: string step?: string
intent?: string
email?: string email?: string
resolved_email?: string resolved_email?: string
pending_email?: string
existing_account_email?: string
suggested_email?: string
} }
function persistPendingAuthSession(redirect?: string) { function persistPendingAuthSession(redirect?: string) {
@@ -392,12 +434,34 @@ function hasSuggestedProfile(completion: {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url) return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
} }
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string { function normalizedPendingState(value: string | null | undefined): string {
return (completion.email || completion.resolved_email || '').trim() return value?.trim().toLowerCase() || ''
} }
function resolvePendingAccountAction(completion: LinuxDoPendingActionResponse): 'none' | 'create_account' | 'bind_login' { function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
const raw = (completion.step || completion.error || '').trim().toLowerCase() return (
completion.pending_email ||
completion.existing_account_email ||
completion.email ||
completion.resolved_email ||
completion.suggested_email ||
''
).trim()
}
function resolvePendingAccountAction(
completion: LinuxDoPendingActionResponse
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') { if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account' return 'create_account'
} }
@@ -418,6 +482,14 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
totpUserEmailMasked.value = '' totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion) const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') { if (action === 'create_account') {
pendingAccountEmail.value = email pendingAccountEmail.value = email
canReturnToCreateAccount.value = true canReturnToCreateAccount.value = true
@@ -470,28 +542,6 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
} }
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -601,10 +651,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
} catch (e: unknown) { } catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email)
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed')) accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false

View File

@@ -19,6 +19,7 @@
v-if=" v-if="
needsInvitation || needsInvitation ||
needsAdoptionConfirmation || needsAdoptionConfirmation ||
needsChooser ||
needsCreateAccount || needsCreateAccount ||
needsBindLogin || needsBindLogin ||
needsTotpChallenge needsTotpChallenge
@@ -118,6 +119,42 @@
</button> </button>
</template> </template>
<template v-else-if="needsChooser">
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{
pendingAccountEmail
? `Suggested email: ${pendingAccountEmail}`
: `Choose whether to bind an existing ${providerName} account or create a new one.`
}}
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Create new account
</button>
</div>
</div>
</div>
</template>
<template v-else-if="needsCreateAccount"> <template v-else-if="needsCreateAccount">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Enter an email address to create your account and continue. Enter an email address to create your account and continue.
@@ -284,7 +321,7 @@ const suggestedAvatarUrl = ref('')
const adoptDisplayName = ref(true) const adoptDisplayName = ref(true)
const adoptAvatar = ref(true) const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false) const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none') const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('') const pendingAccountEmail = ref('')
const bindLoginEmail = ref('') const bindLoginEmail = ref('')
const bindLoginPassword = ref('') const bindLoginPassword = ref('')
@@ -299,6 +336,7 @@ const totpError = ref('')
const totpUserEmailMasked = ref('') const totpUserEmailMasked = ref('')
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account') const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login') const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
type PendingOidcCompletion = PendingOAuthExchangeResponse & { type PendingOidcCompletion = PendingOAuthExchangeResponse & {
@@ -307,6 +345,7 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
resolved_email?: string resolved_email?: string
existing_account_email?: string existing_account_email?: string
email?: string email?: string
suggested_email?: string
provider_fallback?: string provider_fallback?: string
intent?: string intent?: string
requires_2fa?: boolean requires_2fa?: boolean
@@ -430,12 +469,24 @@ function extractPendingAccountEmail(completion: PendingOidcCompletion): string {
completion.existing_account_email || completion.existing_account_email ||
completion.resolved_email || completion.resolved_email ||
completion.email || completion.email ||
completion.suggested_email ||
'' ''
).trim() ).trim()
} }
function resolvePendingAccountAction(completion: PendingOidcCompletion): 'none' | 'create_account' | 'bind_login' { function resolvePendingAccountAction(
completion: PendingOidcCompletion
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent) const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose'
) {
return 'choose_account_action'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') { if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account' return 'create_account'
} }
@@ -462,6 +513,14 @@ function applyPendingAccountAction(completion: PendingOidcCompletion) {
totpUserEmailMasked.value = '' totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion) const email = extractPendingAccountEmail(completion)
if (action === 'choose_account_action') {
pendingAccountEmail.value = email
bindLoginEmail.value = email
bindLoginPassword.value = ''
canReturnToCreateAccount.value = false
return
}
if (action === 'create_account') { if (action === 'create_account') {
pendingAccountEmail.value = email pendingAccountEmail.value = email
canReturnToCreateAccount.value = true canReturnToCreateAccount.value = true
@@ -514,28 +573,6 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
} }
function isCreateAccountRecoveryError(error: unknown): boolean {
const data = (error as {
response?: {
data?: {
reason?: string
error?: string
code?: string
step?: string
intent?: string
}
}
}).response?.data
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
.map(value => value?.trim().toLowerCase())
.filter((value): value is string => Boolean(value))
return states.includes('email_exists') ||
states.includes('bind_login_required') ||
states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email')
}
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
@@ -645,10 +682,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
} catch (e: unknown) { } catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email)
return
}
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed')) accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false

View File

@@ -18,6 +18,7 @@
<div <div
v-if=" v-if="
needsInvitation || needsInvitation ||
needsChooser ||
needsAdoptionConfirmation || needsAdoptionConfirmation ||
needsCreateAccount || needsCreateAccount ||
needsBindLogin || needsBindLogin ||
@@ -103,7 +104,7 @@
{{ {{
isSubmitting isSubmitting
? t('auth.oidc.completing') ? t('auth.oidc.completing')
: t('auth.oidc.completeRegistration') : t('auth.oidc.completeRegistration')
}} }}
</button> </button>
@@ -147,6 +148,43 @@
</div> </div>
</template> </template>
<template v-else-if="needsChooser">
<div
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Choose how to continue
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Pick whether to bind an existing account or create a new one.
</p>
</div>
<button
data-testid="wechat-choice-bind-existing"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="switchToBindLoginMode()"
>
Bind existing account
</button>
<button
data-testid="wechat-choice-create-account"
type="button"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode()"
>
Create new account
</button>
</div>
</div>
</template>
<template v-else-if="needsAdoptionConfirmation"> <template v-else-if="needsAdoptionConfirmation">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Review the {{ providerName }} profile details before continuing. Review the {{ providerName }} profile details before continuing.
@@ -168,13 +206,46 @@
@submit="handleCreateAccount" @submit="handleCreateAccount"
@switch-to-bind="switchToBindLoginMode" @switch-to-bind="switchToBindLoginMode"
/> />
<button
v-if="showBackToChooser"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToChoiceMode"
>
Back to options
</button>
</template> </template>
<template v-else-if="needsBindLogin"> <template v-else-if="needsBindLogin">
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-gray-700 dark:text-gray-300">
Log in to an existing account to bind this {{ providerName }} sign-in. Bind this {{ providerName }} sign-in to an existing account.
</p> </p>
<div class="space-y-3"> <div
v-if="hasCurrentAuthToken"
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
Bind the current account
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
Bind this WeChat identity to the account currently signed in on this browser.
</p>
</div>
<button
data-testid="existing-account-submit"
type="button"
class="btn btn-primary w-full"
:disabled="isSubmitting"
@click="handleBindCurrentAccount"
>
{{ isSubmitting ? t('common.processing') : 'Bind current account' }}
</button>
</div>
</div>
<div v-else class="space-y-3">
<input <input
v-model="bindLoginEmail" v-model="bindLoginEmail"
data-testid="wechat-bind-login-email" data-testid="wechat-bind-login-email"
@@ -201,20 +272,15 @@
> >
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }} {{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
</button> </button>
<button
v-if="canReturnToCreateAccount"
class="btn btn-secondary w-full"
:disabled="isSubmitting"
@click="switchToCreateAccountMode"
>
Use a different email
</button>
</div> </div>
<transition name="fade"> <button
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400"> v-if="showBackToChooser"
{{ accountActionError }} class="btn btn-secondary w-full"
</p> :disabled="isSubmitting"
</transition> @click="switchToChoiceMode"
>
Back to options
</button>
</template> </template>
<template v-else-if="needsTotpChallenge"> <template v-else-if="needsTotpChallenge">
@@ -253,6 +319,12 @@
</div> </div>
</transition> </transition>
<transition name="fade">
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
{{ accountActionError }}
</p>
</transition>
<transition name="fade"> <transition name="fade">
<div <div
v-if="errorMessage" v-if="errorMessage"
@@ -314,6 +386,7 @@ const appStore = useAppStore()
const isProcessing = ref(true) const isProcessing = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const needsInvitation = ref(false) const needsInvitation = ref(false)
const needsChooser = ref(false)
const invitationCode = ref('') const invitationCode = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const invitationError = ref('') const invitationError = ref('')
@@ -325,13 +398,12 @@ const existingAccountEmail = ref('')
const adoptDisplayName = ref(true) const adoptDisplayName = ref(true)
const adoptAvatar = ref(true) const adoptAvatar = ref(true)
const needsAdoptionConfirmation = ref(false) const needsAdoptionConfirmation = ref(false)
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none') const pendingAccountAction = ref<'none' | 'choice' | 'create_account' | 'bind_login'>('none')
const pendingAccountEmail = ref('') const pendingAccountEmail = ref('')
const bindLoginEmail = ref('') const bindLoginEmail = ref('')
const bindLoginPassword = ref('') const bindLoginPassword = ref('')
const legacyPendingOAuthToken = ref('') const legacyPendingOAuthToken = ref('')
const accountActionError = ref('') const accountActionError = ref('')
const canReturnToCreateAccount = ref(false)
const needsTotpChallenge = ref(false) const needsTotpChallenge = ref(false)
const totpTempToken = ref('') const totpTempToken = ref('')
const totpCode = ref('') const totpCode = ref('')
@@ -340,12 +412,17 @@ const totpUserEmailMasked = ref('')
const bindSuccessMessage = t('profile.authBindings.bindSuccess') const bindSuccessMessage = t('profile.authBindings.bindSuccess')
const providerName = 'WeChat' const providerName = 'WeChat'
const showBackToChooser = computed(
() => pendingAccountAction.value === 'create_account' || pendingAccountAction.value === 'bind_login'
)
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account') const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login') const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken())) const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
type PendingWeChatCompletion = PendingOAuthExchangeResponse & { type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
step?: string step?: string
status?: string
state?: string
pending_email?: string pending_email?: string
resolved_email?: string resolved_email?: string
existing_account_email?: string existing_account_email?: string
@@ -489,11 +566,6 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use
intent, intent,
}) })
const email = existingAccountEmail.value.trim()
if (email) {
params.set('email', email)
}
return `${normalized}/auth/oauth/wechat/start?${params.toString()}` return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
} }
@@ -502,6 +574,7 @@ function buildExistingAccountResumePath(): string | null {
if (!mode) { if (!mode) {
return null return null
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
wechat_bind_existing: '1', wechat_bind_existing: '1',
redirect: resolveRedirectTarget(), redirect: resolveRedirectTarget(),
@@ -538,26 +611,31 @@ function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<stri
return payload return payload
} }
async function handleExistingAccountBinding() { async function handleBindCurrentAccount() {
const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null
? resolveWeChatOAuthUnavailableMessage() ? resolveWeChatOAuthUnavailableMessage()
: '' : ''
const startURL = resolveWeChatStartURL('bind_current_user')
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
}
async function handleExistingAccountBinding() {
if (getAuthToken()) { if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user') await handleBindCurrentAccount()
if (!startURL) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
return return
} }
const resumePath = buildExistingAccountResumePath() const resumePath = buildExistingAccountResumePath()
if (!resumePath) { if (!resumePath) {
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage() errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
return return
} }
@@ -606,17 +684,29 @@ function extractPendingAccountEmail(completion: PendingWeChatCompletion): string
function resolvePendingAccountAction( function resolvePendingAccountAction(
completion: PendingWeChatCompletion completion: PendingWeChatCompletion
): 'none' | 'create_account' | 'bind_login' { ): 'none' | 'choice' | 'create_account' | 'bind_login' {
const raw = normalizedPendingState(completion.step || completion.error || completion.intent) const raw = normalizedPendingState(
completion.step || completion.status || completion.state || completion.error || completion.intent
)
if (
raw === 'choice' ||
raw === 'choose_account_action_required' ||
raw === 'choose_account_action' ||
raw === 'choose_account' ||
raw === 'choose' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'choice'
}
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') { if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account' return 'create_account'
} }
if ( if (
raw === 'bind_login_required' || raw === 'bind_login_required' ||
raw === 'bind_login' || raw === 'bind_login'
raw === 'existing_account_binding_required' ||
raw === 'existing_account_required' ||
raw === 'adopt_existing_user_by_email'
) { ) {
return 'bind_login' return 'bind_login'
} }
@@ -627,6 +717,7 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
const action = resolvePendingAccountAction(completion) const action = resolvePendingAccountAction(completion)
pendingAccountAction.value = action pendingAccountAction.value = action
accountActionError.value = '' accountActionError.value = ''
needsChooser.value = false
needsTotpChallenge.value = false needsTotpChallenge.value = false
totpTempToken.value = '' totpTempToken.value = ''
totpCode.value = '' totpCode.value = ''
@@ -634,20 +725,22 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
totpUserEmailMasked.value = '' totpUserEmailMasked.value = ''
const email = extractPendingAccountEmail(completion) const email = extractPendingAccountEmail(completion)
pendingAccountEmail.value = email
if (action === 'create_account') { if (action === 'create_account') {
pendingAccountEmail.value = email
canReturnToCreateAccount.value = true
return return
} }
if (action === 'bind_login') { if (action === 'bind_login') {
bindLoginEmail.value = email bindLoginEmail.value = email
bindLoginPassword.value = '' bindLoginPassword.value = ''
canReturnToCreateAccount.value = true
return return
} }
canReturnToCreateAccount.value = false if (action === 'choice') {
needsChooser.value = true
bindLoginPassword.value = ''
return
}
} }
function applyTotpChallenge(completion: PendingWeChatCompletion): boolean { function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
@@ -656,6 +749,7 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
} }
pendingAccountAction.value = 'none' pendingAccountAction.value = 'none'
needsChooser.value = false
needsInvitation.value = false needsInvitation.value = false
needsAdoptionConfirmation.value = false needsAdoptionConfirmation.value = false
needsTotpChallenge.value = true needsTotpChallenge.value = true
@@ -669,18 +763,26 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
function switchToBindLoginMode(nextEmail?: string) { function switchToBindLoginMode(nextEmail?: string) {
pendingAccountAction.value = 'bind_login' pendingAccountAction.value = 'bind_login'
needsChooser.value = false
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim() bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
bindLoginPassword.value = '' bindLoginPassword.value = ''
accountActionError.value = '' accountActionError.value = ''
canReturnToCreateAccount.value = true
} }
function switchToCreateAccountMode() { function switchToCreateAccountMode() {
pendingAccountAction.value = 'create_account' pendingAccountAction.value = 'create_account'
needsChooser.value = false
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim() pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
accountActionError.value = '' accountActionError.value = ''
} }
function switchToChoiceMode() {
pendingAccountAction.value = 'choice'
needsChooser.value = true
bindLoginPassword.value = ''
accountActionError.value = ''
}
function getRequestErrorMessage(error: unknown, fallback: string): string { function getRequestErrorMessage(error: unknown, fallback: string): string {
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } } const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
@@ -705,7 +807,9 @@ function isCreateAccountRecoveryError(error: unknown): boolean {
return states.includes('email_exists') || return states.includes('email_exists') ||
states.includes('bind_login_required') || states.includes('bind_login_required') ||
states.includes('bind_login') || states.includes('bind_login') ||
states.includes('adopt_existing_user_by_email') states.includes('adopt_existing_user_by_email') ||
states.includes('existing_account_required') ||
states.includes('existing_account_binding_required')
} }
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
@@ -818,7 +922,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
} catch (e: unknown) { } catch (e: unknown) {
if (isCreateAccountRecoveryError(e)) { if (isCreateAccountRecoveryError(e)) {
switchToBindLoginMode(payload.email) switchToChoiceMode()
pendingAccountEmail.value = payload.email.trim()
bindLoginEmail.value = payload.email.trim()
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
return return
} }
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed')) accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
@@ -878,20 +985,15 @@ onMounted(async () => {
} }
if (typeof route.query.email === 'string') { if (typeof route.query.email === 'string') {
existingAccountEmail.value = route.query.email const email = route.query.email.trim()
existingAccountEmail.value = email
bindLoginEmail.value = email
pendingAccountEmail.value = email
} }
if (route.query.wechat_bind_existing === '1') { if (route.query.wechat_bind_existing === '1') {
if (getAuthToken()) { if (getAuthToken()) {
const startURL = resolveWeChatStartURL('bind_current_user') await handleBindCurrentAccount()
if (!startURL) {
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
return return
} }