@@ -40,6 +40,7 @@ import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
4040import { getAccessControlConfig , isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
4141import { holdAdminRemovalStops , stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops'
4242import { createAnonymousSession , ensureAnonymousUserExists } from '@/lib/auth/anonymous'
43+ import { stableExternalAccountId } from '@/lib/auth/connectors/account-id'
4344import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
4445import {
4546 applyRegistrationGate ,
@@ -142,9 +143,11 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
142143import { PlatformEvents } from '@/lib/core/telemetry'
143144import { trustedProxies } from '@/lib/core/utils/request'
144145import { getBaseUrl , isLocalhostUrl , parseOriginList } from '@/lib/core/utils/urls'
146+ import { CredentialAccountConflictError } from '@/lib/credentials/draft-hooks'
145147import {
146148 captureOAuthCredentialDraftBinding ,
147149 consumeOAuthCredentialDraftBinding ,
150+ findCredentialReconnectConflictRedirect ,
148151 processCredentialDraft ,
149152} from '@/lib/credentials/draft-processor'
150153import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,8 +505,9 @@ export const auth = betterAuth({
502505 const modifiedAccount = { ...account }
503506
504507 if ( context ?. path . startsWith ( '/oauth2/callback/' ) ) {
508+ let draftId : string | undefined
505509 try {
506- await captureOAuthCredentialDraftBinding ( context , ( ) => getOAuthState ( ) )
510+ draftId = await captureOAuthCredentialDraftBinding ( context , ( ) => getOAuthState ( ) )
507511 } catch ( error ) {
508512 logger . error ( '[account.create.before] Failed to read OAuth credential draft state' , {
509513 userId : account . userId ,
@@ -512,6 +516,26 @@ export const auth = betterAuth({
512516 } )
513517 throw error
514518 }
519+
520+ /**
521+ * The after-hook runs once the callback has already answered, so a
522+ * reconnect onto an account another credential in the workspace
523+ * uses has to be refused here, before the row exists, to send the
524+ * user back with a reason instead of a 500.
525+ */
526+ if ( draftId ) {
527+ const conflictRedirectUrl = await findCredentialReconnectConflictRedirect ( {
528+ draftId,
529+ userId : account . userId ,
530+ providerId : account . providerId ,
531+ externalAccountId : account . accountId ,
532+ oauthState : await getOAuthState ( ) ,
533+ } )
534+ if ( conflictRedirectUrl ) {
535+ consumeOAuthCredentialDraftBinding ( context )
536+ throw new APIError ( 'FOUND' , undefined , { location : conflictRedirectUrl } )
537+ }
538+ }
515539 }
516540
517541 if ( account . accessToken && isSalesforceOAuthProviderId ( account . providerId ) ) {
@@ -544,19 +568,15 @@ export const auth = betterAuth({
544568 /**
545569 * Migrate credentials from stale account rows to the newly created one.
546570 *
547- * Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a
548- * random UUID to the stable external ID so that Better Auth never
549- * blocks cross-user connections — keep the two in step. This means
550- * re-connecting the same external identity creates a new row. We detect
551- * the stale siblings here by comparing the stable prefix (everything
552- * before the trailing UUID), migrate any credential FKs to the new row,
553- * then delete the stale rows.
571+ * Re-connecting the same external identity creates a new row (see
572+ * `stableExternalAccountId`). We detect the stale siblings here by
573+ * comparing the stable prefix, migrate any credential FKs to the new
574+ * row, then delete the stale rows.
554575 */
555576 try {
556- const UUID_SUFFIX_RE = / - [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ /
557- const stablePrefix = account . accountId . replace ( UUID_SUFFIX_RE , '' )
577+ const stablePrefix = stableExternalAccountId ( account . accountId )
558578
559- if ( stablePrefix && stablePrefix !== account . accountId ) {
579+ if ( stablePrefix ) {
560580 const siblings = await db
561581 . select ( { id : schema . account . id , accountId : schema . account . accountId } )
562582 . from ( schema . account )
@@ -569,7 +589,7 @@ export const auth = betterAuth({
569589 )
570590
571591 const staleRows = siblings . filter (
572- ( row ) => row . accountId . replace ( UUID_SUFFIX_RE , '' ) === stablePrefix
592+ ( row ) => stableExternalAccountId ( row . accountId ) === stablePrefix
573593 )
574594
575595 if ( staleRows . length > 0 ) {
@@ -653,12 +673,21 @@ export const auth = betterAuth({
653673 accountId : account . id ,
654674 } )
655675 } catch ( error ) {
656- logger . error ( '[account.create.after] Failed to process credential draft' , {
657- userId : account . userId ,
658- providerId : account . providerId ,
659- error,
660- } )
661- if ( credentialDraftBinding . draftId ) throw error
676+ if ( error instanceof CredentialAccountConflictError ) {
677+ logger . warn ( '[account.create.after] Refused a conflicting credential reconnect' , {
678+ userId : account . userId ,
679+ providerId : account . providerId ,
680+ credentialId : error . credentialId ,
681+ conflictingCredentialId : error . conflictingCredentialId ,
682+ } )
683+ } else {
684+ logger . error ( '[account.create.after] Failed to process credential draft' , {
685+ userId : account . userId ,
686+ providerId : account . providerId ,
687+ error,
688+ } )
689+ if ( credentialDraftBinding . draftId ) throw error
690+ }
662691 }
663692 }
664693
0 commit comments