Merge pull request #2655 from ye4241/feat/oidc-trust-verified-email-fast-path

feat(oidc): 上游邮箱已验证时跳过 choice 页直接登录注册
This commit is contained in:
Wesley Liddick 2026-05-21 14:47:08 +08:00 committed by GitHub
commit 35901a174b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 203 additions and 1 deletions

View File

@ -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: strings.TrimSpace(identity.ProviderType),
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: 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: reason=%s", infraerrors.Reason(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
}

View File

@ -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

View File

@ -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)