From 39fe7aa0ebc5cc5091690d6aa380a8a4e79784b4 Mon Sep 17 00:00:00 2001 From: ye4241 Date: Thu, 21 May 2026 10:16:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(oidc):=20=E4=B8=8A=E6=B8=B8=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E5=B7=B2=E9=AA=8C=E8=AF=81=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=20choice=20=E9=A1=B5=E7=9B=B4=E6=8E=A5=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前 OIDC 首次登录无条件创建 choose_account_action_required 的 pending session,即使 force_email_on_third_party_signup 关闭,前端仍然会强制 弹出"创建账号 / 绑定已有账号"的二选一界面,并展示内部合成邮箱 (oidc-xxx@oidc-connect.invalid),用户体验差。 本次复用已存在的 LoginOrRegisterVerifiedEmailOAuth 路径(原本仅供 github/google 使用),在以下条件全部满足时跳过 choice 页,直接 信任上游身份完成注册/登录: - force_email_on_third_party_signup = false - 邀请码模式未启用 - 上游声明 email_verified = true 且 compat_email 非空 - 本地不存在同邮箱已有账号 失败时(如邮箱后缀不在白名单、注册关闭等)自动回退到现有 choice 流程,行为完全向后兼容。 测试覆盖: - TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity - TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired - TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled --- backend/internal/handler/auth_oidc_oauth.go | 85 +++++++++++++ .../internal/handler/auth_oidc_oauth_test.go | 117 ++++++++++++++++++ .../internal/service/auth_email_oauth_auto.go | 2 +- 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index c7c517c8..1cc95009 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -454,6 +454,28 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { } } + // Fast-path: when the upstream supplies a verified email and the deployment + // does not require any extra confirmation (force_email_on_third_party_signup + // disabled + invitation code disabled) and no local account collides with the + // upstream email, trust the upstream identity and finish login without + // rendering the choice page. Any failure falls through to the regular choice + // flow below. + if compatEmailUser == nil && + strings.TrimSpace(compatEmail) != "" && + emailVerified != nil && *emailVerified { + if h.tryOIDCVerifiedEmailFastPath( + c, + frontendCallback, + redirectTo, + identityRef, + compatEmail, + username, + upstreamClaims, + ) { + return + } + } + if h.isForceEmailOnThirdPartySignup(c.Request.Context()) { if err := h.createOIDCOAuthChoicePendingSession( c, @@ -1190,3 +1212,66 @@ func oidcClearCookie(c *gin.Context, name string, secure bool) { SameSite: http.SameSiteLaxMode, }) } + +// tryOIDCVerifiedEmailFastPath attempts to skip the choice/pending page when +// the upstream identity carries a verified email and the deployment does not +// require any additional confirmation. It mirrors the behaviour already used +// for verified github/google logins via LoginOrRegisterVerifiedEmailOAuth. +// +// Returns true when the flow has completed (token issued and browser +// redirected); a false return tells the caller to fall through to the regular +// choice flow. +func (h *AuthHandler) tryOIDCVerifiedEmailFastPath( + c *gin.Context, + frontendCallback string, + redirectTo string, + identity service.PendingAuthIdentityKey, + compatEmail string, + username string, + upstreamClaims map[string]any, +) bool { + if h == nil || h.authService == nil || h.settingSvc == nil { + return false + } + ctx := c.Request.Context() + if h.isForceEmailOnThirdPartySignup(ctx) { + return false + } + if h.settingSvc.IsInvitationCodeEnabled(ctx) { + return false + } + + upstreamMetadata := make(map[string]any, len(upstreamClaims)) + for k, v := range upstreamClaims { + upstreamMetadata[k] = v + } + input := service.EmailOAuthIdentityInput{ + ProviderType: "oidc", + ProviderKey: strings.TrimSpace(identity.ProviderKey), + ProviderSubject: strings.TrimSpace(identity.ProviderSubject), + Email: strings.TrimSpace(strings.ToLower(compatEmail)), + EmailVerified: true, + Username: strings.TrimSpace(username), + DisplayName: pendingSessionStringValue(upstreamClaims, "suggested_display_name"), + AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"), + UpstreamMetadata: upstreamMetadata, + } + tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "") + if err != nil { + log.Printf("[OIDC OAuth] verified-email fast path skipped: %v", err) + return false + } + if err := h.ensureBackendModeAllowsUser(ctx, user); err != nil { + log.Printf("[OIDC OAuth] verified-email fast path blocked by backend mode: %v", err) + return false + } + + fragment := url.Values{} + fragment.Set("access_token", tokenPair.AccessToken) + fragment.Set("refresh_token", tokenPair.RefreshToken) + fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn)) + fragment.Set("token_type", "Bearer") + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) + return true +} diff --git a/backend/internal/handler/auth_oidc_oauth_test.go b/backend/internal/handler/auth_oidc_oauth_test.go index 3216d51e..1bb04195 100644 --- a/backend/internal/handler/auth_oidc_oauth_test.go +++ b/backend/internal/handler/auth_oidc_oauth_test.go @@ -926,6 +926,123 @@ func TestCompleteOIDCOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUser require.Nil(t, storedSession.ConsumedAt) } +func TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, false) + t.Cleanup(func() { _ = client.Close() }) + + ctx := context.Background() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil) + + identity := service.PendingAuthIdentityKey{ + ProviderType: "oidc", + ProviderKey: "https://issuer.example.com", + ProviderSubject: "fast-path-subject", + } + completed := handler.tryOIDCVerifiedEmailFastPath( + c, + "/auth/oidc/callback", + "/dashboard", + identity, + "fastpath@example.com", + "fastpath_user", + map[string]any{ + "suggested_display_name": "Fast Path", + "suggested_avatar_url": "", + }, + ) + require.True(t, completed) + require.Equal(t, http.StatusFound, recorder.Code) + + location := recorder.Header().Get("Location") + require.Contains(t, location, "/auth/oidc/callback") + require.Contains(t, location, "access_token=") + require.Contains(t, location, "refresh_token=") + require.Contains(t, location, "token_type=Bearer") + + user, err := client.User.Query().Where(dbuser.EmailEQ("fastpath@example.com")).Only(ctx) + require.NoError(t, err) + require.Equal(t, "fastpath_user", user.Username) + require.Equal(t, "oidc", user.SignupSource) + + identityCount, err := client.AuthIdentity.Query().Where( + authidentity.ProviderTypeEQ("oidc"), + authidentity.ProviderKeyEQ("https://issuer.example.com"), + authidentity.ProviderSubjectEQ("fast-path-subject"), + authidentity.UserIDEQ(user.ID), + ).Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, identityCount) + + pendingCount, err := client.PendingAuthSession.Query().Count(ctx) + require.NoError(t, err) + require.Zero(t, pendingCount) +} + +func TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, true) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil) + + identity := service.PendingAuthIdentityKey{ + ProviderType: "oidc", + ProviderKey: "https://issuer.example.com", + ProviderSubject: "fast-path-skipped-invitation", + } + completed := handler.tryOIDCVerifiedEmailFastPath( + c, + "/auth/oidc/callback", + "/dashboard", + identity, + "invite-only@example.com", + "invite_only_user", + map[string]any{}, + ) + require.False(t, completed) + require.NotEqual(t, http.StatusFound, recorder.Code) + + userCount, err := client.User.Query().Where(dbuser.EmailEQ("invite-only@example.com")).Count(context.Background()) + require.NoError(t, err) + require.Zero(t, userCount) +} + +func TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + settingValues: map[string]string{ + service.SettingKeyForceEmailOnThirdPartySignup: "true", + }, + }) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil) + + identity := service.PendingAuthIdentityKey{ + ProviderType: "oidc", + ProviderKey: "https://issuer.example.com", + ProviderSubject: "fast-path-skipped-force-email", + } + completed := handler.tryOIDCVerifiedEmailFastPath( + c, + "/auth/oidc/callback", + "/dashboard", + identity, + "force-email@example.com", + "force_email_user", + map[string]any{}, + ) + require.False(t, completed) + + userCount, err := client.User.Query().Where(dbuser.EmailEQ("force-email@example.com")).Count(context.Background()) + require.NoError(t, err) + require.Zero(t, userCount) +} + type oidcProviderFixture struct { Subject string PreferredUsername string diff --git a/backend/internal/service/auth_email_oauth_auto.go b/backend/internal/service/auth_email_oauth_auto.go index 56fd4004..4db845c2 100644 --- a/backend/internal/service/auth_email_oauth_auto.go +++ b/backend/internal/service/auth_email_oauth_auto.go @@ -49,7 +49,7 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth( } providerType := normalizeOAuthSignupSource(input.ProviderType) - if providerType != "github" && providerType != "google" { + if providerType != "github" && providerType != "google" && providerType != "oidc" { return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid") } providerKey := strings.TrimSpace(input.ProviderKey) From 55554adc185cf3ddd796f513bcd1a7fd3a7e50a8 Mon Sep 17 00:00:00 2001 From: ye4241 Date: Thu, 21 May 2026 11:55:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore(oidc):=20=E5=9B=9E=E5=BA=94=20Copilot?= =?UTF-8?q?=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderType 从 identity.ProviderType 取(不再硬编码) - fast-path 日志改用 infraerrors.Reason(err) 避免泄露 PII / 降噪 --- backend/internal/handler/auth_oidc_oauth.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 1cc95009..c82f7e2a 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -1246,7 +1246,7 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath( upstreamMetadata[k] = v } input := service.EmailOAuthIdentityInput{ - ProviderType: "oidc", + ProviderType: strings.TrimSpace(identity.ProviderType), ProviderKey: strings.TrimSpace(identity.ProviderKey), ProviderSubject: strings.TrimSpace(identity.ProviderSubject), Email: strings.TrimSpace(strings.ToLower(compatEmail)), @@ -1258,11 +1258,11 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath( } tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "") if err != nil { - log.Printf("[OIDC OAuth] verified-email fast path skipped: %v", err) + log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err)) return false } if err := h.ensureBackendModeAllowsUser(ctx, user); err != nil { - log.Printf("[OIDC OAuth] verified-email fast path blocked by backend mode: %v", err) + log.Printf("[OIDC OAuth] verified-email fast path blocked by backend mode: reason=%s", infraerrors.Reason(err)) return false }