Skip to content

Fix duplicate account linking flow#2743

Open
gentlemandev wants to merge 2 commits into
mainfrom
elie/inb-285-account-already-exists-error-references-a-merge-option-that
Open

Fix duplicate account linking flow#2743
gentlemandev wants to merge 2 commits into
mainfrom
elie/inb-285-account-already-exists-error-references-a-merge-option-that

Conversation

@gentlemandev

Copy link
Copy Markdown
Collaborator

Fixes the duplicate account-linking path so same-provider duplicate emails merge instead of showing an incorrect merge-option error. Cross-provider duplicates now show actionable account-exists guidance.

  • Merge same-provider duplicate email accounts through the linking flow.
  • Keep cross-provider duplicate email attempts on an account-exists redirect with updated UI copy.
  • Tests: pnpm --filter inbox-zero-ai test --run utils/oauth/account-linking.test.ts.

@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
inbox-zero Ignored Ignored Preview May 26, 2026 6:13pm

},
);
redirectUrl.searchParams.set("error", "account_already_exists_use_merge");
return {

@hex-security-app hex-security-app Bot May 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Automatic cross-user account merge on email match enables account hijacking

The new code path at line 98–102 of account-linking.ts triggers an unconditional mergeAccount() whenever a providerEmail is found in the database under a different user but with the same OAuth provider — even if the providerAccountId (OAuth sub) is completely different from the one already stored.

Attack path (email-recycling / cross-sub takeover):

  1. Victim (User A) has alice@gmail.com (Google sub 123) registered in the app.
  2. Attacker (User B, a separate app account) authenticates through Google OAuth with alice@gmail.com using a different sub 456 (e.g., the address was recycled by Google, or was obtained via a different Google Workspace tenant).
  3. In the callback: the providerAccountId = "456" lookup returns null (existingAccount is null).
  4. handleAccountLinking enters the email-lookup branch, finds the address under User A with provider "google", and — because provider strings match — returns { type: "merge" } with User A's accountId and userId as source.
  5. mergeAccount is called immediately with no further confirmation: if User A has only one linked account, User A's entire app record is deleted and all their data (rules, AI settings, premium subscription via transferPremiumDuringMerge, linked accounts) is re-owned by User B.

Why this is different from the pre-existing merge path: The pre-existing merge (line 134–138, unchanged) fires only when providerAccountId matches — i.e., the provider itself confirms it is the same identity. The new path fires on email string equality across potentially unrelated OAuth identities. Email addresses are not stable identity tokens: they can be recycled, reassigned across tenants, or shared across Microsoft consumer vs. business accounts.

The old code deliberately blocked this scenario with account_already_exists_use_merge, requiring an explicit user-initiated confirmation before proceeding. That guard is now removed for same-provider cases.

Impact: Full takeover of victim's app-level data and account deletion, triggered solely by the attacker possessing an OAuth account that shares an email string with the victim. No victim interaction required.

Prompt To Fix With AI
The merge at lines 98–102 of `apps/web/utils/oauth/account-linking.ts` should NOT fire just because the email address matches for the same provider. The `providerAccountId` (OAuth `sub`) is the authoritative, stable identity token — email strings are not.

Two safe options:

Option A — Require providerAccountId to match for auto-merge (minimal change):
Remove the entire new same-provider email-match branch (lines 68–102). Keep the cross-provider redirect for the `account_already_exists` error. Let the pre-existing merge path (lines 134–138, triggered when `existingAccount.userId !== targetUserId`) handle legitimate same-sub re-linking; it already covers the intended use-case.

Option B — Gate the merge on a user-confirmation step:
Restore the old `account_already_exists_use_merge` redirect for same-provider cases too, and re-introduce a user-confirmation flow (e.g., a separate "I confirm I own this account" step that re-generates an OAuth URL with an `isExistingAccount=true` flag embedded in the signed state). Only after the user explicitly confirms should `handleAccountLinking` return `{ type: "merge" }` for an email-matched, different-sub account.

If the intent is to handle Google Workspace domain migrations where the sub changes but the email stays the same, that scenario should be handled out-of-band (e.g., admin-initiated migration endpoint) rather than silently during a normal account-linking flow.

Severity: high | Confidence: 80%

Fixed in 25a60351.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — taking the Option A path. The auto-merge has been removed; same-provider duplicate-email collisions now redirect with error=account_already_exists just like the cross-provider case, since the OAuth sub (providerAccountId) is the only stable identity we should auto-merge on. The pre-existing merge branch (when providerAccountId matches but userId differs) still handles the legitimate same-identity merge case. This also keeps INB-285 fixed because the misleading account_already_exists_use_merge error is gone.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

No issues found across 3 files

Linked issue analysis

Linked issue: INB-285: Account Already Exists Error References a Merge Option That Does Not Exist

Status Acceptance criteria Notes
Merge same-provider duplicate email accounts through the linking flow (same-provider duplicates should return a merge result instead of showing a misleading merge-option error). account-linking.ts now returns a { type: 'merge', sourceAccountId, sourceUserId } when an existing email account belongs to a different user but the same provider; tests assert the merge result.
Cross-provider duplicate email attempts should redirect to an account-exists flow (no merge) and the UI error copy should be updated to remove the misleading 'select Yes ... merge' instruction. When the existing account's provider differs from the incoming provider, the handler returns a redirect with error=account_already_exists; the accounts page copy key was changed and the message now gives actionable guidance instead of telling the user to select a non-existent merge option. Tests were updated to expect the new error param.

Re-trigger cubic

Email is not a stable OAuth identity — the providerAccountId (sub) is.
Redirect with account_already_exists when an email collides across users,
regardless of provider. The same-identity merge path (matching
providerAccountId) is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/utils/oauth/account-linking.ts">

<violation number="1" location="apps/web/utils/oauth/account-linking.ts:76">
P1: This change collapses all duplicate-email cases into redirect, so same-provider duplicates in the create path no longer merge.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

redirectUrl.searchParams.set("error", "account_already_exists_use_merge");

return {
type: "redirect",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This change collapses all duplicate-email cases into redirect, so same-provider duplicates in the create path no longer merge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/oauth/account-linking.ts, line 76:

<comment>This change collapses all duplicate-email cases into redirect, so same-provider duplicates in the create path no longer merge.</comment>

<file context>
@@ -58,47 +58,25 @@ export async function handleAccountLinking({
-        type: "merge",
-        sourceAccountId: existingEmailAccount.accountId,
-        sourceUserId: existingEmailAccount.userId,
+        type: "redirect",
+        response: createAccountLinkingRedirect({
+          query: { error: "account_already_exists" },
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants