@@ -36,7 +36,7 @@ import {
3636 isMicrosoftProvider ,
3737 PROACTIVE_REFRESH_THRESHOLD_DAYS ,
3838} from '@/lib/oauth/microsoft'
39- import { refreshOAuthToken } from '@/lib/oauth/oauth'
39+ import { refreshOAuthToken , TOKEN_REFRESH_TIMEOUT_MS } from '@/lib/oauth/oauth'
4040import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config'
4141import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
4242import {
@@ -872,18 +872,50 @@ function isOAuthAccessTokenExpiring(
872872}
873873
874874/**
875- * Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in
876- * lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request
877- * a follower of one refresh , so the TTL covers the provider call plus generous
878- * headroom for the surrounding DB reads and the fan-out write, and followers
879- * poll for the lock 's full lifetime so a slow-but-successful refresh is still
880- * observed rather than reported as a failure. These budgets are latency knobs,
881- * not correctness guarantees — chain integrity under lock expiry or unlocked
882- * concurrent writers is enforced by the version-guarded fan-out
883- * (`ifChainUnchangedSince` in lib/oauth/slack.ts ).
875+ * Lock budgets sized past the provider call: the lease covers
876+ * { @link TOKEN_REFRESH_TIMEOUT_MS} plus headroom for the account read before it and
877+ * the rotated write after it , so a leader still talking to a slow provider keeps its
878+ * lease instead of letting a second leader start a competing rotation; and followers
879+ * poll for the lease 's full lifetime, so a slow-but-successful refresh is observed
880+ * rather than reported as a failure. Both are latency knobs, not correctness
881+ * guarantees: a lease is only ever a lease, and chain integrity under lock expiry or
882+ * an unlocked writer is enforced at the write, which rotates a chain only from the
883+ * refresh token it started from (`ifChainUnchangedSince` for a Slack installation ).
884884 */
885- const SLACK_LOCK_TTL_SEC = 30
886- const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000
885+ const REFRESH_LOCK_HEADROOM_MS = 15_000
886+ const REFRESH_LOCK_TTL_SEC = Math . ceil ( ( TOKEN_REFRESH_TIMEOUT_MS + REFRESH_LOCK_HEADROOM_MS ) / 1000 )
887+ const REFRESH_FOLLOWER_MAX_WAIT_MS = REFRESH_LOCK_TTL_SEC * 1000
888+
889+ interface StoredChain {
890+ accessToken : string | null
891+ accessTokenExpiresAt : Date | null
892+ refreshToken : string | null
893+ }
894+
895+ /** The chain an account row holds now, or nothing when the account is gone. */
896+ async function readStoredChain ( accountId : string ) : Promise < StoredChain | undefined > {
897+ const [ stored ] = await db
898+ . select ( {
899+ accessToken : account . accessToken ,
900+ accessTokenExpiresAt : account . accessTokenExpiresAt ,
901+ refreshToken : account . refreshToken ,
902+ } )
903+ . from ( account )
904+ . where ( eq ( account . id , accountId ) )
905+ . limit ( 1 )
906+ return stored
907+ }
908+
909+ /**
910+ * The stored access token when it can still serve a request, as a follower would take it: a
911+ * chain another writer just rotated carries one, and a token that has already expired is no
912+ * answer at all.
913+ */
914+ function usableStoredToken ( stored : StoredChain , providerId : string ) : string | null {
915+ return stored . accessToken && ! isOAuthAccessTokenExpiring ( stored . accessTokenExpiresAt , providerId )
916+ ? stored . accessToken
917+ : null
918+ }
887919
888920async function performCoalescedRefresh ( {
889921 accountId,
@@ -926,11 +958,8 @@ async function performCoalescedRefresh({
926958 const refreshPromise = coalesceLocally ( lockKey , ( ) =>
927959 withLeaderLock < string > ( {
928960 key : lockKey ,
929- // Installation-keyed Slack locks gather followers from every sibling row,
930- // so their wait and the lock TTL must outlast the 15s provider timeout —
931- // the 3s/10s defaults would fail followers early and let a second leader
932- // start a concurrent rotation mid-refresh.
933- ...( slackTeamId ? { maxWaitMs : SLACK_FOLLOWER_MAX_WAIT_MS , ttlSec : SLACK_LOCK_TTL_SEC } : { } ) ,
961+ ttlSec : REFRESH_LOCK_TTL_SEC ,
962+ maxWaitMs : REFRESH_FOLLOWER_MAX_WAIT_MS ,
934963 onLeader : async ( ) => {
935964 try {
936965 let refreshTokenToUse = refreshToken
@@ -981,18 +1010,25 @@ async function performCoalescedRefresh({
9811010 message : result . message ,
9821011 } )
9831012 if ( result . errorCode && isTerminalRefreshError ( result . errorCode ) ) {
984- // A refresh that lost a race with a concurrent connect fails with
985- // a revoked/rotated-out token even though the installation just
986- // got a live chain — dead-flagging then would take down a healthy
987- // credential for an hour.
1013+ // A refresh that lost a race with a concurrent connect or a newer
1014+ // rotation fails with a revoked/rotated-out token even though the
1015+ // account just got a live chain — dead-flagging then would take
1016+ // down a healthy credential for an hour.
9881017 if (
9891018 slackChainVersion &&
9901019 ( await hasSlackChainMoved ( slackTeamId ! , slackChainVersion ) )
9911020 ) {
9921021 logger . info ( 'Skipping dead flag: Slack chain moved during refresh' , logContext )
993- } else {
994- await markCredentialDead ( scopeKey , result . errorCode )
1022+ return null
9951023 }
1024+ if ( ! slackTeamId ) {
1025+ const stored = await readStoredChain ( accountId )
1026+ if ( stored && stored . refreshToken !== refreshToken ) {
1027+ logger . info ( 'Skipping dead flag: chain moved during refresh' , logContext )
1028+ return usableStoredToken ( stored , providerId )
1029+ }
1030+ }
1031+ await markCredentialDead ( scopeKey , result . errorCode )
9961032 }
9971033 return null
9981034 }
@@ -1027,7 +1063,33 @@ async function performCoalescedRefresh({
10271063 )
10281064 }
10291065
1030- await db . update ( account ) . set ( updateData ) . where ( eq ( account . id , accountId ) )
1066+ /**
1067+ * The chain is rotated only from the refresh token this refresh started from.
1068+ * A lease is not mutual exclusion: it can expire under a slow provider or a
1069+ * paused process while the leader is still running, and an unconditional
1070+ * write would then let this refresh overwrite a newer rotation with a chain
1071+ * the provider has already retired, which the next refresh pays for as
1072+ * `invalid_grant` and, under reuse detection, as a revoked grant. When no row
1073+ * matches, another writer rotated first: its chain is the live one, so this
1074+ * caller uses what is stored and never retries the provider.
1075+ */
1076+ const rotated = await db
1077+ . update ( account )
1078+ . set ( updateData )
1079+ . where ( and ( eq ( account . id , accountId ) , eq ( account . refreshToken , refreshToken ) ) )
1080+ . returning ( { id : account . id } )
1081+ if ( rotated . length === 0 ) {
1082+ const stored = await readStoredChain ( accountId )
1083+ if ( ! stored ) {
1084+ logger . warn ( 'Rotation write found no account; the credential is gone' , logContext )
1085+ return null
1086+ }
1087+ logger . warn (
1088+ 'Rotation write lost to a newer chain; using the stored token' ,
1089+ logContext
1090+ )
1091+ return usableStoredToken ( stored , providerId )
1092+ }
10311093 }
10321094
10331095 logger . info ( 'Successfully refreshed access token' , logContext )
0 commit comments