fix: harden oidc compat email and email bind tx
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
@@ -323,18 +324,13 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
if emailVerified == nil {
|
||||
emailVerified = idClaims.EmailVerified
|
||||
}
|
||||
if cfg.RequireEmailVerified {
|
||||
if emailVerified == nil || !*emailVerified {
|
||||
redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
if userInfoClaims.Subject != "" && idClaims.Subject != "" && strings.TrimSpace(userInfoClaims.Subject) != strings.TrimSpace(idClaims.Subject) {
|
||||
redirectOAuthError(c, frontendCallback, "subject_mismatch", "userinfo subject does not match id_token", "")
|
||||
return
|
||||
}
|
||||
|
||||
identityKey := oidcIdentityKey(issuer, subject)
|
||||
compatEmail := strings.TrimSpace(firstNonEmpty(userInfoClaims.Email, idClaims.Email))
|
||||
email := oidcSyntheticEmailFromIdentityKey(identityKey)
|
||||
username := firstNonEmpty(
|
||||
userInfoClaims.Username,
|
||||
@@ -357,6 +353,9 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
"suggested_display_name": firstNonEmpty(userInfoClaims.DisplayName, idClaims.Name, username),
|
||||
"suggested_avatar_url": userInfoClaims.AvatarURL,
|
||||
}
|
||||
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
|
||||
upstreamClaims["compat_email"] = compatEmail
|
||||
}
|
||||
if intent == oauthIntentBindCurrentUser {
|
||||
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, oidcOAuthBindUserCookieName)
|
||||
if err != nil {
|
||||
@@ -416,6 +415,40 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
compatEmailUser, err := h.findOIDCCompatEmailUser(c.Request.Context(), compatEmail)
|
||||
if err != nil {
|
||||
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 {
|
||||
redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
||||
if err := h.createOAuthEmailRequiredPendingSession(c, identityRef, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||
@@ -473,6 +506,30 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
||||
redirectToFrontendCallback(c, frontendCallback)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
|
||||
client := h.entClient()
|
||||
if client == nil {
|
||||
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if email == "" ||
|
||||
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
|
||||
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
|
||||
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
userEntity, err := findUserByNormalizedEmail(ctx, client, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUserNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
|
||||
}
|
||||
return userEntity, nil
|
||||
}
|
||||
|
||||
type completeOIDCOAuthRequest struct {
|
||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
|
||||
@@ -245,6 +245,127 @@ func TestOIDCOAuthCallbackCreatesLoginPendingSessionForExistingUser(t *testing.T
|
||||
require.Nil(t, completion["error"])
|
||||
}
|
||||
|
||||
func TestOIDCOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing.T) {
|
||||
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||
Subject: "oidc-subject-compat",
|
||||
PreferredUsername: "oidc_compat",
|
||||
DisplayName: "OIDC Compat Display",
|
||||
AvatarURL: "https://cdn.example/oidc-compat.png",
|
||||
Email: "legacy@example.com",
|
||||
EmailVerified: true,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail("legacy@example.com").
|
||||
SetUsername("legacy-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-compat", nil)
|
||||
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/dashboard"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
|
||||
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-compat"))
|
||||
c.Request = req
|
||||
|
||||
handler.OIDCOAuthCallback(c)
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||
|
||||
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
session, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "adopt_existing_user_by_email", session.Intent)
|
||||
require.NotNil(t, session.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
||||
require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
|
||||
|
||||
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||
require.Equal(t, "/dashboard", completion["redirect"])
|
||||
require.Equal(t, "bind_login_required", completion["step"])
|
||||
require.Equal(t, existingUser.Email, completion["email"])
|
||||
_, hasAccessToken := completion["access_token"]
|
||||
require.False(t, hasAccessToken)
|
||||
}
|
||||
|
||||
func TestOIDCOAuthCallbackAllowsCompatEmailBindWhenUpstreamEmailIsUnverified(t *testing.T) {
|
||||
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||
Subject: "oidc-subject-unverified-compat",
|
||||
PreferredUsername: "oidc_unverified",
|
||||
DisplayName: "OIDC Unverified Compat Display",
|
||||
AvatarURL: "https://cdn.example/oidc-unverified.png",
|
||||
Email: "owner@example.com",
|
||||
EmailVerified: false,
|
||||
})
|
||||
defer cleanup()
|
||||
cfg.RequireEmailVerified = true
|
||||
|
||||
handler, client := newOIDCOAuthHandlerAndClient(t, false, cfg)
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail("owner@example.com").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback?code=oidc-code&state=state-unverified-compat", nil)
|
||||
req.AddCookie(encodedCookie(oidcOAuthStateCookieName, "state-unverified-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthRedirectCookie, "/settings/connections"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthVerifierCookie, "verifier-unverified-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthNonceCookie, "nonce-oidc-subject-unverified-compat"))
|
||||
req.AddCookie(encodedCookie(oidcOAuthIntentCookieName, oauthIntentLogin))
|
||||
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-unverified-compat"))
|
||||
c.Request = req
|
||||
|
||||
handler.OIDCOAuthCallback(c)
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "/auth/oidc/callback", recorder.Header().Get("Location"))
|
||||
|
||||
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
session, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "adopt_existing_user_by_email", session.Intent)
|
||||
require.NotNil(t, session.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
||||
require.Equal(t, "owner@example.com", session.UpstreamIdentityClaims["compat_email"])
|
||||
|
||||
completion := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||
require.Equal(t, "/settings/connections", completion["redirect"])
|
||||
require.Equal(t, "bind_login_required", completion["step"])
|
||||
require.Equal(t, existingUser.Email, completion["email"])
|
||||
}
|
||||
|
||||
func TestOIDCOAuthCallbackCreatesInvitationPendingSessionWhenSignupRequiresInvite(t *testing.T) {
|
||||
cfg, cleanup := newOIDCTestProvider(t, oidcProviderFixture{
|
||||
Subject: "oidc-subject-invite",
|
||||
|
||||
Reference in New Issue
Block a user