From e63fcb028268106aee0c27f00dca2be09d52121d Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Sun, 13 Sep 2026 21:05:40 +0530 Subject: [PATCH] fix(auth): let the linking rule actually decide, instead of Auth.js refusing 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. --- src/auth.config.ts | 33 ++++++++++++++++++++++++++++++++- src/auth.ts | 7 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/auth.config.ts b/src/auth.config.ts index 4029ab0..b94b8ef 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -30,6 +30,35 @@ import { pickVerifiedGitHubEmail, type GitHubEmail } from "@/lib/auth/githubEmai * middleware only ever *reads* an existing session. */ +/** + * WHY `allowDangerousEmailAccountLinking` IS SET, AND WHAT MAKES IT SAFE + * + * Without it, Auth.js refuses outright to attach an OAuth identity to an + * existing account with the same email, and answers OAuthAccountNotLinked. That + * is the correct default for an app with no opinion of its own — but it is + * fatal here, because every account that predates this migration already exists + * with an email address, so every one of those users would be permanently + * locked out of Google and GitHub sign-in. + * + * The flag is named "dangerous" because Auth.js cannot tell whether the + * provider verified the address. Turning it on alone would mean anyone able to + * add a victim's address to their own Google or GitHub account inherits the + * victim's Commit account and its role. + * + * What makes it safe is that we answer that question ourselves, before Auth.js + * acts on it. The `signIn` callback in src/auth.ts applies + * `mayLinkToExistingUser`, which refuses unless the provider positively asserts + * the address is verified — and @auth/core runs `handleAuthorized` (the signIn + * callback) before `handleLoginOrRegister` (the linking), so returning false + * there is a prevention rather than a post-mortem. + * + * These two are therefore a pair. Removing the signIn callback, or weakening + * mayLinkToExistingUser, silently turns this flag back into what its name says + * it is. The GitHub userinfo override below exists for the same reason: the + * stock provider discards the verified flag, and a guess in that argument + * defeats the guard entirely. + */ + const GITHUB_API = "https://api.github.com"; export const authConfig = { @@ -40,9 +69,11 @@ export const authConfig = { providers: [ // Google is an OIDC provider, so `email_verified` arrives as a standard // claim and needs no help. - Google, + Google({ allowDangerousEmailAccountLinking: true }), GitHub({ + allowDangerousEmailAccountLinking: true, + userinfo: { url: `${GITHUB_API}/user`, diff --git a/src/auth.ts b/src/auth.ts index c28a474..90865f0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -277,6 +277,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ * `handleLoginOrRegister` in the @auth/core callback route, which is what * makes returning false here a prevention rather than a post-mortem. * + * THIS FUNCTION IS LOAD-BEARING. Both providers in src/auth.config.ts set + * `allowDangerousEmailAccountLinking: true`, which switches off Auth.js's + * own refusal to attach an OAuth identity to an existing account. That flag + * is only safe because this callback answers the question Auth.js cannot: + * whether the provider actually verified the address. Delete or weaken this + * and the flag becomes exactly what its name says. + * * The rule itself lives in src/lib/auth/linking.ts with its own tests. This * function's only job is to feed it honest inputs — which is also why * src/auth.config.ts overrides the GitHub userinfo request. The stock