feat(identity): rewrite mergeUsers to bind, don't delete (Phase 2a)#3715
Merged
Conversation
Confirming an "I have two accounts" link no longer destroys the secondary WorkOS user. App-state rows still move to the primary so existing reads keep working, but the secondary's WorkOS user stays alive — each linked email remains a real, working sign-in credential. Both WorkOS users end up bound to one identity (the primary's); the secondary's orphan singleton identity gets dropped. Auth middleware: when a non-primary binding signs in, swap req.user.id to the identity's primary workos_user_id so app-state reads land on the right person. The actual authenticated WorkOS user is preserved on req.user.authWorkosUserId for WorkOS API calls and audit logs. Verify-email-link page copy updated: "signing in with either email leads to the same workspace" instead of "this cannot be undone." Phase 2b (admin "create + bind" tool) is the direct fix for the Ahmed class of escalation; this PR is the prerequisite that makes such a binding useful (without id-swap, the new gmail user would log in to an empty workspace). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| */ | ||
|
|
||
| import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; | ||
| import { initializeDatabase, closeDatabase, getPool } from '../../src/db/client.js'; |
Code review + security review caught real issues: H1 (confused deputy): admin.ts widget createToken and account-linking.ts updateUser were passing req.user.id post-id-swap to WorkOS APIs that operate on the auth credential. Both now use authWorkosUserId ?? id. Other WorkOS callers (createOrganizationMembership, listOrganization- Memberships) intentionally keep canonical id — they're identity-scoped, not credential-scoped. H2 (silent takeover): the pre-existing email-link confused-deputy attack (attacker initiates link to victim, victim confirms, attacker becomes primary) was previously loud because it deleted the victim's WorkOS user. Bind-not-delete makes it silent and persistent. Block the merge-existing- account path entirely at initiation (POST /api/me/linked-emails) and defense-in-depth at verify time. Consolidating two existing accounts is now admin-only — Phase 2b adds that tool. Bare alias adds (target email has no existing WorkOS user) are unaffected. H3 (cache stale): export invalidateSessionsForUsers() from auth and call it from mergeUsers post-commit. Sessions for either user get evicted so subsequent requests re-resolve the new binding instead of serving a stale id-swap. Plus L2 (cleaner LEFT JOIN in attachIdentityId) and a missing test for "throws when primary lacks identity binding." Deferred as follow-ups: M1 (audit-log forensics — record both canonical and auth ids; touches many sites; not yet load-bearing because no users have non-singleton identities until Phase 2b ships); M2 (primary-deletion DoS via WorkOS dashboard — needs webhook-side promote-on-delete logic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2a of the identity layer. Confirming an "I have two accounts" link no longer destroys the secondary WorkOS user — both linked emails stay live as real sign-in credentials, both bound to one identity. Reads still work because we still move data to the primary; the auth middleware id-swaps non-primary logins to the canonical user.
Builds on Phase 1 (#3711, merged). Phase 2b will be the admin "create + bind" tool that finally unblocks Ahmed; this PR is the prerequisite that makes the binding actually useful (without id-swap, a non-primary login would land in an empty workspace).
What changes
server/src/db/user-merge-db.tsdeleteUsercall and theworkosparameter (no longer needed).identity_workos_usersrow to the primary's identity, markedis_primary = FALSE.workos_user_deletedandwarningsfromUserMergeSummary(deletion no longer happens).server/src/middleware/auth.tsattachIdentityIdto also resolve the identity's primaryworkos_user_id. When the authenticated user is a non-primary binding, swapreq.user.idto the canonical and stash the original onreq.user.authWorkosUserId.server/src/types.tsWorkOSUser.authWorkosUserIdfor code that needs the actual auth user (WorkOS API calls, audit logs).idclarifies the new contract: "canonical workos_user_id for app-state queries."server/src/routes/account-linking.tsgetWorkos()arg tomergeUsers.server/src/http.tsworkos!arg from the Google-alias-merge call site.Test plan
merge-bind-not-delete.test.ts(5 tests) — secondary WorkOS user stays alive; both bindings point at one identity; secondary's binding is non-primary; orphan identity is dropped; org_memberships still move to primary; audit row written.identity-layer.test.ts, 6 tests).set-primary-email.test.ts(8),membership-webhook.test.ts(60). 79/79 across all auth/identity/merge.tsc --noEmitclean.main— not introduced here.Phase plan recap
identity_workos_users(tells the truth); dropuser_email_aliases🤖 Generated with Claude Code