diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 3a21c69a..f6c826b7 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -1107,6 +1107,15 @@ func applyPendingOAuthBindingTx( } if decision != nil && (decision.IdentityID == nil || *decision.IdentityID != identity.ID) { + if _, err := tx.Client().IdentityAdoptionDecision.Update(). + Where( + identityadoptiondecision.IdentityIDEQ(identity.ID), + identityadoptiondecision.IDNEQ(decision.ID), + ). + ClearIdentityID(). + Save(ctx); err != nil { + return err + } if _, err := tx.Client().IdentityAdoptionDecision.UpdateOneID(decision.ID). SetIdentityID(identity.ID). Save(ctx); err != nil { diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index b1ac1c4b..dfd1aef1 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -537,6 +537,114 @@ func TestExchangePendingOAuthCompletionLoginFalseFalseBindsIdentityWithoutAdopti require.NotNil(t, storedSession.ConsumedAt) } +func TestExchangePendingOAuthCompletionLoginReassignsExistingDecisionIdentityReference(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, false) + ctx := context.Background() + + userEntity, err := client.User.Create(). + SetEmail("login-reassign@example.com"). + SetUsername("legacy-name"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + existingIdentity, err := client.AuthIdentity.Create(). + SetUserID(userEntity.ID). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("login-reassign-123"). + SetMetadata(map[string]any{}). + Save(ctx) + require.NoError(t, err) + + previousSession, err := client.PendingAuthSession.Create(). + SetSessionToken("login-reassign-previous-session-token"). + SetIntent("login"). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("login-reassign-123"). + SetTargetUserID(userEntity.ID). + SetResolvedEmail(userEntity.Email). + SetBrowserSessionKey("login-reassign-previous-browser-session-key"). + SetLocalFlowState(map[string]any{ + oauthCompletionResponseKey: map[string]any{ + "access_token": "previous-access-token", + }, + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + previousDecision, err := client.IdentityAdoptionDecision.Create(). + SetPendingAuthSessionID(previousSession.ID). + SetIdentityID(existingIdentity.ID). + SetAdoptDisplayName(true). + SetAdoptAvatar(true). + Save(ctx) + require.NoError(t, err) + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("login-reassign-session-token"). + SetIntent("login"). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("login-reassign-123"). + SetTargetUserID(userEntity.ID). + SetResolvedEmail(userEntity.Email). + SetBrowserSessionKey("login-reassign-browser-session-key"). + SetUpstreamIdentityClaims(map[string]any{ + "suggested_display_name": "Login Reassign", + "suggested_avatar_url": "https://cdn.example/login-reassign.png", + }). + SetLocalFlowState(map[string]any{ + oauthCompletionResponseKey: map[string]any{ + "access_token": "access-token", + }, + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + _, err = client.IdentityAdoptionDecision.Create(). + SetPendingAuthSessionID(session.ID). + SetAdoptDisplayName(false). + SetAdoptAvatar(false). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("login-reassign-browser-session-key")}) + ginCtx.Request = req + + handler.ExchangePendingOAuthCompletion(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + + reloadedPrevious, err := client.IdentityAdoptionDecision.Get(ctx, previousDecision.ID) + require.NoError(t, err) + require.Nil(t, reloadedPrevious.IdentityID) + + currentDecision, err := client.IdentityAdoptionDecision.Query(). + Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)). + Only(ctx) + require.NoError(t, err) + require.NotNil(t, currentDecision.IdentityID) + require.Equal(t, existingIdentity.ID, *currentDecision.IdentityID) + + storedSession, err := client.PendingAuthSession.Query(). + Where(pendingauthsession.IDEQ(session.ID)). + Only(ctx) + require.NoError(t, err) + require.NotNil(t, storedSession.ConsumedAt) +} + func TestExchangePendingOAuthCompletionLoginWithoutDecisionStillBindsIdentity(t *testing.T) { handler, client := newOAuthPendingFlowTestHandler(t, false) ctx := context.Background()