@@ -27,6 +27,13 @@ import {
2727 isMicrosoftProvider ,
2828 PROACTIVE_REFRESH_THRESHOLD_DAYS ,
2929} from '@/lib/oauth/microsoft'
30+ import {
31+ extractSlackTeamId ,
32+ fanOutSlackTokenChain ,
33+ getFreshestSlackChain ,
34+ hasSlackChainMoved ,
35+ isSlackProvider ,
36+ } from '@/lib/oauth/slack'
3037import {
3138 getRecentTerminalError ,
3239 isTerminalRefreshError ,
@@ -658,25 +665,51 @@ interface CoalescedRefreshOptions {
658665 accountId : string
659666 providerId : string
660667 refreshToken : string
668+ /** External provider account id (`account.accountId`), used to scope Slack refreshes per installation. */
669+ providerAccountId ?: string | null
661670 requestId ?: string
662671 userId ?: string
663672}
664673
674+ /**
675+ * Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in
676+ * lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request
677+ * a follower of one refresh, so the TTL covers the provider call plus generous
678+ * headroom for the surrounding DB reads and the fan-out write, and followers
679+ * poll for the lock's full lifetime so a slow-but-successful refresh is still
680+ * observed rather than reported as a failure. These budgets are latency knobs,
681+ * not correctness guarantees — chain integrity under lock expiry or unlocked
682+ * concurrent writers is enforced by the version-guarded fan-out
683+ * (`ifChainUnchangedSince` in lib/oauth/slack.ts).
684+ */
685+ const SLACK_LOCK_TTL_SEC = 30
686+ const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000
687+
665688async function performCoalescedRefresh ( {
666689 accountId,
667690 providerId,
668691 refreshToken,
692+ providerAccountId,
669693 requestId,
670694 userId,
671695} : CoalescedRefreshOptions ) : Promise < string | null > {
696+ /**
697+ * Slack bot tokens are per-installation (team × app): every account row for
698+ * one team holds a copy of the same rotating chain, so refreshes are locked,
699+ * dead-flagged, and written per installation rather than per row.
700+ */
701+ const slackTeamId = isSlackProvider ( providerId ) ? extractSlackTeamId ( providerAccountId ) : null
702+ const scopeKey = slackTeamId ? `slack:${ slackTeamId } ` : accountId
703+
672704 const logContext = {
673705 ...( requestId ? { requestId } : { } ) ,
674706 ...( userId ? { userId } : { } ) ,
707+ ...( slackTeamId ? { slackTeamId } : { } ) ,
675708 providerId,
676709 accountId,
677710 }
678711
679- const deadCode = await getRecentTerminalError ( accountId )
712+ const deadCode = await getRecentTerminalError ( scopeKey )
680713 if ( deadCode ) {
681714 logger . warn ( 'Skipping refresh: credential recently failed' , {
682715 ...logContext ,
@@ -685,39 +718,99 @@ async function performCoalescedRefresh({
685718 return null
686719 }
687720
688- const lockKey = `oauth:refresh:${ accountId } `
721+ const lockKey = `oauth:refresh:${ scopeKey } `
689722
690723 const refreshPromise = coalesceLocally ( lockKey , ( ) =>
691724 withLeaderLock < string > ( {
692725 key : lockKey ,
726+ // Installation-keyed Slack locks gather followers from every sibling row,
727+ // so their wait and the lock TTL must outlast the 15s provider timeout —
728+ // the 3s/10s defaults would fail followers early and let a second leader
729+ // start a concurrent rotation mid-refresh.
730+ ...( slackTeamId ? { maxWaitMs : SLACK_FOLLOWER_MAX_WAIT_MS , ttlSec : SLACK_LOCK_TTL_SEC } : { } ) ,
693731 onLeader : async ( ) => {
694732 try {
695- const result = await refreshOAuthToken ( providerId , refreshToken )
733+ let refreshTokenToUse = refreshToken
734+ let slackChainVersion : Date | null = null
735+ if ( slackTeamId ) {
736+ const freshest = await getFreshestSlackChain ( slackTeamId )
737+ if ( ! freshest ) {
738+ throw new Error (
739+ `No refresh-capable account row found for Slack installation ${ slackTeamId } `
740+ )
741+ }
742+ slackChainVersion = freshest . chainVersion
743+ if (
744+ freshest . accessToken &&
745+ freshest . accessTokenExpiresAt &&
746+ freshest . accessTokenExpiresAt > new Date ( )
747+ ) {
748+ await fanOutSlackTokenChain (
749+ slackTeamId ,
750+ {
751+ accessToken : freshest . accessToken ,
752+ refreshToken : freshest . refreshToken ,
753+ accessTokenExpiresAt : freshest . accessTokenExpiresAt ,
754+ } ,
755+ { ifChainUnchangedSince : freshest . chainVersion }
756+ )
757+ logger . info ( 'Reused freshest Slack installation token' , logContext )
758+ return freshest . accessToken
759+ }
760+ refreshTokenToUse = freshest . refreshToken
761+ }
762+
763+ const result = await refreshOAuthToken ( providerId , refreshTokenToUse )
696764
697765 if ( ! result . ok ) {
698766 logger . error ( 'Failed to refresh token' , {
699767 ...logContext ,
700768 errorCode : result . errorCode ,
701769 } )
702770 if ( result . errorCode && isTerminalRefreshError ( result . errorCode ) ) {
703- await markCredentialDead ( accountId , result . errorCode )
771+ // A refresh that lost a race with a concurrent connect fails with
772+ // a revoked/rotated-out token even though the installation just
773+ // got a live chain — dead-flagging then would take down a healthy
774+ // credential for an hour.
775+ if (
776+ slackChainVersion &&
777+ ( await hasSlackChainMoved ( slackTeamId ! , slackChainVersion ) )
778+ ) {
779+ logger . info ( 'Skipping dead flag: Slack chain moved during refresh' , logContext )
780+ } else {
781+ await markCredentialDead ( scopeKey , result . errorCode )
782+ }
704783 }
705784 return null
706785 }
707786
708- const updateData : Record < string , unknown > = {
709- accessToken : result . accessToken ,
710- accessTokenExpiresAt : new Date ( Date . now ( ) + result . expiresIn * 1000 ) ,
711- updatedAt : new Date ( ) ,
712- }
713- if ( result . refreshToken && result . refreshToken !== refreshToken ) {
714- updateData . refreshToken = result . refreshToken
715- }
716- if ( isMicrosoftProvider ( providerId ) ) {
717- updateData . refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry ( )
718- }
787+ const accessTokenExpiresAt = new Date ( Date . now ( ) + result . expiresIn * 1000 )
788+
789+ if ( slackTeamId ) {
790+ await fanOutSlackTokenChain (
791+ slackTeamId ,
792+ {
793+ accessToken : result . accessToken ,
794+ refreshToken : result . refreshToken || refreshTokenToUse ,
795+ accessTokenExpiresAt,
796+ } ,
797+ { ifChainUnchangedSince : slackChainVersion ?? undefined }
798+ )
799+ } else {
800+ const updateData : Record < string , unknown > = {
801+ accessToken : result . accessToken ,
802+ accessTokenExpiresAt,
803+ updatedAt : new Date ( ) ,
804+ }
805+ if ( result . refreshToken && result . refreshToken !== refreshToken ) {
806+ updateData . refreshToken = result . refreshToken
807+ }
808+ if ( isMicrosoftProvider ( providerId ) ) {
809+ updateData . refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry ( )
810+ }
719811
720- await db . update ( account ) . set ( updateData ) . where ( eq ( account . id , accountId ) )
812+ await db . update ( account ) . set ( updateData ) . where ( eq ( account . id , accountId ) )
813+ }
721814
722815 logger . info ( 'Successfully refreshed access token' , logContext )
723816 return result . accessToken
@@ -774,6 +867,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
774867 const connections = await db
775868 . select ( {
776869 id : account . id ,
870+ providerAccountId : account . accountId ,
777871 accessToken : account . accessToken ,
778872 refreshToken : account . refreshToken ,
779873 accessTokenExpiresAt : account . accessTokenExpiresAt ,
@@ -813,6 +907,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
813907 accountId : credential . id ,
814908 providerId,
815909 refreshToken : credential . refreshToken ! ,
910+ providerAccountId : credential . providerAccountId ,
816911 userId,
817912 } )
818913 if ( fresh ) return fresh
@@ -915,6 +1010,7 @@ export async function resolveCredentialAccessToken(
9151010 accountId : resolvedCredentialId ,
9161011 providerId : credential . providerId ,
9171012 refreshToken : credential . refreshToken ! ,
1013+ providerAccountId : credential . accountId ,
9181014 requestId,
9191015 userId : credential . userId ,
9201016 } )
@@ -1019,6 +1115,7 @@ export async function refreshTokenIfNeeded(
10191115 accountId : resolvedCredentialId ,
10201116 providerId : credential . providerId ,
10211117 refreshToken : credential . refreshToken ! ,
1118+ providerAccountId : credential . accountId ,
10221119 requestId,
10231120 userId : credential . userId ,
10241121 } )
0 commit comments