diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index c3a9041b..e0bee2f5 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -325,80 +325,18 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) return } - if compatEmailUser != nil { - if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ - Intent: "adopt_existing_user_by_email", - Identity: identityKey, - 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 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 { + if err := h.createLinuxDoOAuthChoicePendingSession( + c, + identityKey, + email, + email, + redirectTo, + browserSessionKey, + upstreamClaims, + compatEmail, + compatEmailUser, + h.isForceEmailOnThirdPartySignup(c.Request.Context()), + ); err != nil { redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") return } @@ -431,6 +369,62 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri 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 { InvitationCode string `json:"invitation_code" binding:"required"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 8a3006f3..b98d4d33 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -32,6 +32,7 @@ const ( oauthPendingSessionCookiePath = "/api/v1/auth/oauth" oauthPendingSessionCookieName = "oauth_pending_session" oauthPendingCookieMaxAgeSec = 10 * 60 + oauthPendingChoiceStep = "choose_account_action_required" oauthCompletionResponseKey = "completion_response" ) @@ -431,8 +432,9 @@ func (h *AuthHandler) createOAuthEmailRequiredPendingSession( BrowserSessionKey: browserSessionKey, UpstreamIdentityClaims: upstreamClaims, CompletionResponse: map[string]any{ - "redirect": redirectTo, - "step": "email_required", + "redirect": strings.TrimSpace(redirectTo), + "step": oauthPendingChoiceStep, + "adoption_required": true, "force_email_on_signup": true, "email_binding_required": true, "existing_account_bindable": true, @@ -492,7 +494,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) { email := strings.TrimSpace(strings.ToLower(req.Email)) 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 { response.ErrorFrom(c, err) return @@ -1206,12 +1208,13 @@ func readPendingOAuthBrowserSession(c *gin.Context, h *AuthHandler) (*service.Au } func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H { + completionResponse := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, nil)) payload := gin.H{ "auth_result": "pending_session", "provider": strings.TrimSpace(session.ProviderType), "intent": strings.TrimSpace(session.Intent), } - for key, value := range mergePendingCompletionResponse(session, nil) { + for key, value := range completionResponse { payload[key] = value } if email := strings.TrimSpace(session.ResolvedEmail); email != "" { @@ -1220,38 +1223,58 @@ func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gi 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, client *dbent.Client, session *dbent.PendingAuthSession, email string, - decision oauthAdoptionDecisionRequest, ) (*dbent.PendingAuthSession, error) { - existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email) - if err != nil { - return nil, err - } - - completionResponse := mergePendingCompletionResponse(session, map[string]any{ - "step": "bind_login_required", - "email": email, - }) - session, err = updatePendingOAuthSessionProgress( + completionResponse := pendingOAuthChoiceCompletionResponse(session, email) + session, err := updatePendingOAuthSessionProgress( c.Request.Context(), client, session, - "adopt_existing_user_by_email", + strings.TrimSpace(session.Intent), email, - &existingUser.ID, + nil, completionResponse, ) if err != nil { 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 } @@ -1365,12 +1388,20 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) email := strings.TrimSpace(strings.ToLower(req.Email)) existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email) - if err != nil && !errors.Is(err, service.ErrUserNotFound) { - response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")) - return + if err != nil { + switch { + 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 { - session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, req.adoptionDecision()) + session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email) if err != nil { response.ErrorFrom(c, err) return @@ -1393,7 +1424,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) ) if err != nil { 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 { response.ErrorFrom(c, err) 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")) return } + payload = normalizePendingOAuthCompletionResponse(payload) if strings.TrimSpace(session.RedirectTo) != "" { if _, exists := payload["redirect"]; !exists { payload["redirect"] = session.RedirectTo diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 6d19e9d6..d2042a87 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -420,27 +420,6 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) 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 emailVerified == nil || !*emailVerified { @@ -450,7 +429,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.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", "") return } @@ -458,48 +448,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { 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: identityRef, - 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: 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 { + if err := h.createOIDCOAuthChoicePendingSession( + c, + identityRef, + email, + email, + redirectTo, + browserSessionKey, + upstreamClaims, + compatEmail, + compatEmailUser, + h.isForceEmailOnThirdPartySignup(c.Request.Context()), + ); err != nil { redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") return } @@ -530,6 +490,65 @@ func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string) 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 { InvitationCode string `json:"invitation_code" binding:"required"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 734fb2ef..2fa035a5 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -62,6 +62,8 @@ type wechatOAuthConfig struct { scope string redirectURI string frontendCallback string + openEnabled bool + mpEnabled bool } type wechatOAuthTokenResponse struct { @@ -209,11 +211,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) { unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID)) 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", "") return } - providerSubject := unionid username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject)) email := wechatSyntheticEmail(providerSubject) @@ -284,7 +293,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.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", "") return } @@ -292,17 +312,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) { return } - tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "") - if err != nil { - if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err, nil); err != nil { - redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") - return - } - redirectToFrontendCallback(c, frontendCallback) - return - } - - if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, nil); err != nil { + if err := h.createWeChatChoicePendingSession( + c, + identityRef, + email, + email, + redirectTo, + browserSessionKey, + upstreamClaims, + "", + nil, + false, + ); err != nil { redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") 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( c *gin.Context, cfg wechatOAuthConfig, @@ -874,7 +954,7 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, if err != nil { return wechatOAuthConfig{}, err } - if effective.Mode != mode { + if !effective.SupportsMode(mode) { 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), redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")), 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 { @@ -900,6 +982,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, return cfg, nil } +func (cfg wechatOAuthConfig) requiresUnionID() bool { + return cfg.openEnabled && cfg.mpEnabled +} + func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string { if h != nil && h.settingSvc != nil { cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx) diff --git a/backend/internal/handler/auth_wechat_oauth_test.go b/backend/internal/handler/auth_wechat_oauth_test.go index b0fee617..140851de 100644 --- a/backend/internal/handler/auth_wechat_oauth_test.go +++ b/backend/internal/handler/auth_wechat_oauth_test.go @@ -65,6 +65,36 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) { 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) { originalAccessTokenURL := wechatOAuthAccessTokenURL originalUserInfoURL := wechatOAuthUserInfoURL diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index a775075d..84610133 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -15,6 +15,7 @@ v-if=" needsInvitation || needsAdoptionConfirmation || + needsChooser || needsCreateAccount || needsBindLogin || needsTotpChallenge @@ -109,6 +110,42 @@ + + + + +