Skip to content

fix(auth): let the linking rule actually decide, instead of Auth.js refusing first - #25

Merged
kunjhirapara merged 1 commit into
mainfrom
fix/oauth-account-linking
Sep 13, 2026
Merged

kunjhirapara merged 1 commit into
mainfrom
fix/oauth-account-linking

Conversation

@kunjhirapara

Copy link
Copy Markdown
Owner

Signing in with Google against an account that already exists answers OAuthAccountNotLinked. Every account predating this migration has an email address, so that is every existing user permanently locked out of Google and GitHub sign-in.

The ordering assumption I got wrong

The design was that our signIn callback applies mayLinkToExistingUser and decides. It does run first — @auth/core calls handleAuthorized before handleLoginOrRegister, which I verified when writing it. But approving there turns out not to be sufficient, because handleLoginOrRegister then applies a refusal of its own:

if (userByEmail) {
  if (provider?.allowDangerousEmailAccountLinking) { user = userByEmail; }
  else { throw new OAuthAccountNotLinked("Another account already exists with the same e-mail address"); }
}

Our rule never got to matter. Auth.js had already said no.

Why setting the "dangerous" flag is the safe option here

It is the only way to let the rule decide at all. And the guard that makes it safe already exists — it was built for exactly this:

  • The flag is named dangerous because Auth.js cannot tell whether the provider verified the address. On its own it means anyone able to add a victim's address to their own Google or GitHub account inherits the victim's Commit account and its role.
  • mayLinkToExistingUser answers precisely that question, refuses unless the provider positively asserts the address is verified, and runs before the linking happens.
  • src/auth.config.ts overrides GitHub's userinfo request for the same reason: the stock provider discards the verified flag, and a guess in that argument would defeat the guard entirely.

The two are now a pair, and both ends say so in the code. Removing the signIn callback, or weakening mayLinkToExistingUser, silently turns this flag back into what its name says it is.

Behaviour after this

Provider says the email is verified, and it matches an existing account Linked, existing user and role preserved
Provider does not assert verification signIn returns false → AccessDenied, no link
No existing account with that address New user created as before

Verification

263/263 tests pass, tsc --noEmit clean, production build green. mayLinkToExistingUser and the GitHub verified-email selection both keep their existing test coverage — this PR changes which of the two refusals fires first, not the rule itself.

…efusing first

Signing in with Google against an account that already exists answered
OAuthAccountNotLinked. Every account predating this migration has an email
address, so that is every existing user permanently locked out of Google and
GitHub sign-in.

The cause is an ordering assumption I got wrong. The design was that our signIn
callback applies mayLinkToExistingUser and decides. It does run first --
@auth/core calls handleAuthorized before handleLoginOrRegister -- but approving
there is not sufficient, because handleLoginOrRegister then applies a refusal of
its own: without allowDangerousEmailAccountLinking it throws
OAuthAccountNotLinked whenever getUserByEmail finds a row. Our rule never got to
matter; Auth.js had already said no.

So the flag is now set on both providers, which is the only way to let the rule
decide at all.

What makes that safe is the guard that was built for it. The flag is named
"dangerous" because Auth.js cannot tell whether the provider verified the
address, and turning it on alone means anyone able to add a victim's address to
their own Google or GitHub account inherits the victim's Commit account and its
role. mayLinkToExistingUser answers exactly that question, refuses unless the
provider positively asserts the address is verified, and runs before the linking
happens -- which is also why src/auth.config.ts overrides GitHub's userinfo
request, since the stock provider discards the verified flag and a guess in that
argument defeats the whole thing.

These are now a pair, and both ends say so. Removing the signIn callback or
weakening mayLinkToExistingUser silently turns the flag back into what its name
says it is.
Copilot AI lite review requested due to automatic review settings September 13, 2026 15:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kunjhirapara
kunjhirapara merged commit 22820f5 into main Sep 13, 2026
1 check passed
@kunjhirapara
kunjhirapara deleted the fix/oauth-account-linking branch September 13, 2026 15:38
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