@@ -147,8 +147,8 @@ import { CredentialAccountConflictError } from '@/lib/credentials/draft-hooks'
147147import {
148148 captureOAuthCredentialDraftBinding ,
149149 consumeOAuthCredentialDraftBinding ,
150- findCredentialReconnectConflictRedirect ,
151150 processCredentialDraft ,
151+ refuseConflictingCredentialReconnect ,
152152} from '@/lib/credentials/draft-processor'
153153import { sendEmail } from '@/lib/messaging/email/mailer'
154154import { getFromEmailAddress , getPersonalEmailFrom } from '@/lib/messaging/email/utils'
@@ -297,6 +297,40 @@ async function fetchSalesforceInstanceUrl(
297297 }
298298}
299299
300+ /**
301+ * A fresh Slack connect re-issues the installation's rotating token chain,
302+ * invalidating the copies held by sibling account rows for the same team (Slack
303+ * bot tokens are per-installation, not per-grant). Propagates the new chain so
304+ * every sibling is valid again, and clears the installation's dead flag. A
305+ * failure is logged, never thrown: the connect itself already succeeded.
306+ */
307+ async function propagateFreshSlackConnect ( account : {
308+ userId : string
309+ providerId : string
310+ accountId : string
311+ accessToken ?: string | null
312+ refreshToken ?: string | null
313+ accessTokenExpiresAt ?: Date | null
314+ } ) : Promise < void > {
315+ if ( account . providerId !== 'slack' || ! account . accessToken ) return
316+ try {
317+ const teamId = extractSlackTeamId ( account . accountId )
318+ if ( ! teamId ) return
319+ // Clear the dead flag before fanning out: the connect itself proves the
320+ // installation has live tokens, and a fan-out failure must not leave the
321+ // hour-long flag blocking refreshes.
322+ await clearOAuthRefreshDeadFlag ( `slack:${ teamId } ` )
323+ await fanOutSlackTokenChain ( teamId , {
324+ accessToken : account . accessToken ,
325+ refreshToken : account . refreshToken ?? null ,
326+ accessTokenExpiresAt : account . accessTokenExpiresAt ?? null ,
327+ } )
328+ logger . info ( 'Propagated Slack installation token chain' , { userId : account . userId , teamId } )
329+ } catch ( error ) {
330+ logger . error ( 'Failed to propagate Slack token chain' , { userId : account . userId , error } )
331+ }
332+ }
333+
300334export const auth = betterAuth ( {
301335 baseURL : getBaseUrl ( ) ,
302336 // Where Better Auth sends OAuth callbacks that fail before the flow state is
@@ -503,6 +537,7 @@ export const auth = betterAuth({
503537 create : {
504538 before : async ( account , context ) => {
505539 const modifiedAccount = { ...account }
540+ let reconnectDraftId : string | undefined
506541
507542 if ( context ?. path . startsWith ( '/oauth2/callback/' ) ) {
508543 let draftId : string | undefined
@@ -516,26 +551,7 @@ export const auth = betterAuth({
516551 } )
517552 throw error
518553 }
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- }
554+ reconnectDraftId = draftId
539555 }
540556
541557 if ( account . accessToken && isSalesforceOAuthProviderId ( account . providerId ) ) {
@@ -562,6 +578,27 @@ export const auth = betterAuth({
562578 }
563579 }
564580
581+ /**
582+ * The after-hook runs once the callback has already answered, so a
583+ * reconnect onto an account another credential in the workspace uses
584+ * has to be refused here, before the row exists, to send the user
585+ * back with a reason instead of a 500. The fresh grant still lands
586+ * on the identity's existing rows: the provider may already have
587+ * rotated their tokens away (Slack installations do).
588+ */
589+ if ( context && reconnectDraftId ) {
590+ const conflictRedirectUrl = await refuseConflictingCredentialReconnect ( {
591+ draftId : reconnectDraftId ,
592+ account : modifiedAccount ,
593+ oauthState : await getOAuthState ( ) ,
594+ } )
595+ if ( conflictRedirectUrl ) {
596+ consumeOAuthCredentialDraftBinding ( context )
597+ await propagateFreshSlackConnect ( modifiedAccount )
598+ throw new APIError ( 'FOUND' , undefined , { location : conflictRedirectUrl } )
599+ }
600+ }
601+
565602 return { data : modifiedAccount }
566603 } ,
567604 after : async ( account , context ) => {
@@ -618,40 +655,7 @@ export const auth = betterAuth({
618655 } )
619656 }
620657
621- /**
622- * A fresh Slack connect re-issues the installation's rotating token
623- * chain, invalidating the copies held by sibling account rows for the
624- * same team (Slack bot tokens are per-installation, not per-grant).
625- * Propagate the new chain so every sibling is valid again, and clear
626- * the installation's dead flag.
627- */
628- if ( account . providerId === 'slack' && account . accessToken ) {
629- try {
630- const teamId = extractSlackTeamId ( account . accountId )
631- if ( teamId ) {
632- // Clear the dead flag before fanning out: the connect itself
633- // proves the installation has live tokens, and a fan-out
634- // failure must not leave the hour-long flag blocking refreshes.
635- await clearOAuthRefreshDeadFlag ( `slack:${ teamId } ` )
636- await fanOutSlackTokenChain ( teamId , {
637- accessToken : account . accessToken ,
638- refreshToken : account . refreshToken ?? null ,
639- accessTokenExpiresAt : account . accessTokenExpiresAt ?? null ,
640- } )
641- logger . info ( '[account.create.after] Propagated Slack installation token chain' , {
642- userId : account . userId ,
643- teamId,
644- newAccountId : account . id ,
645- } )
646- }
647- } catch ( error ) {
648- logger . error ( '[account.create.after] Failed to propagate Slack token chain' , {
649- userId : account . userId ,
650- accountId : account . id ,
651- error,
652- } )
653- }
654- }
658+ await propagateFreshSlackConnect ( account )
655659
656660 const isOAuth2Callback = context ?. path . startsWith ( '/oauth2/callback/' ) === true
657661 const credentialDraftBinding = context
0 commit comments