Skip to content

Commit a530185

Browse files
committed
fix(oauth): clear only the recorded revocation on in-place relinks, with no Redis on the sign-in path
1 parent a898b46 commit a530185

3 files changed

Lines changed: 41 additions & 21 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
148148
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
149149
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
150150
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
151-
import { clearOAuthRefreshFailure } from '@/lib/oauth/credential-service'
151+
import { clearRecordedRevocation } from '@/lib/oauth/credential-service'
152152
import {
153153
getMicrosoftRefreshTokenExpiry,
154154
isMicrosoftProvider,
@@ -711,9 +711,9 @@ export const auth = betterAuth({
711711
after: async (account, context) => {
712712
const path = context?.path
713713
if (!path?.startsWith('/oauth2/callback/') && !path?.startsWith('/callback/')) return
714-
// Fails the callback like the draft hooks do, so a reconnect never reports success
715-
// while the old revocation still blocks the credential.
716-
await clearOAuthRefreshFailure(account.id)
714+
// Fails the callback only if this statement fails, so a reconnect never reports
715+
// success while the old revocation still blocks the credential.
716+
await clearRecordedRevocation(account.id)
717717
},
718718
},
719719
},

‎apps/sim/lib/oauth/credential-revocation.integration.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
OAUTH_CREDENTIAL_REVOKED,
2727
} from '@/lib/oauth/credential-revoked'
2828
import {
29+
clearRecordedRevocation,
2930
getCredentialTerminalRefreshError,
3031
refreshTokenIfNeeded,
3132
} from '@/lib/oauth/credential-service'
@@ -307,7 +308,20 @@ describe('OAuth refresh-token revocation against PostgreSQL and Redis', () => {
307308
}
308309
})
309310

310-
it('resumes refreshing after a reconnect that kept the old refresh token', async () => {
311+
it.each([
312+
{
313+
path: 'a credential draft reconnect',
314+
reconnect: () =>
315+
handleReconnectCredential({
316+
draft: { credentialId },
317+
newAccountId: accountId,
318+
workspaceId,
319+
userId,
320+
now: new Date(),
321+
}),
322+
},
323+
{ path: 'an in-place relink', reconnect: () => clearRecordedRevocation(accountId) },
324+
])('resumes refreshing after $path that kept the old refresh token', async ({ reconnect }) => {
311325
await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError)
312326

313327
// A relink with no new refresh token from the provider rewrites only the access token.
@@ -319,13 +333,7 @@ describe('OAuth refresh-token revocation against PostgreSQL and Redis', () => {
319333
updatedAt: new Date(),
320334
})
321335
.where(eq(account.id, accountId))
322-
await handleReconnectCredential({
323-
draft: { credentialId },
324-
newAccountId: accountId,
325-
workspaceId,
326-
userId,
327-
now: new Date(),
328-
})
336+
await reconnect()
329337
await db
330338
.update(account)
331339
.set({ accessTokenExpiresAt: new Date(Date.now() - 60_000) })

‎apps/sim/lib/oauth/credential-service.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,11 +1075,23 @@ export async function getCredentialTerminalRefreshError(
10751075
return errorCode ? { errorCode, providerId: row.providerId } : null
10761076
}
10771077

1078+
/**
1079+
* Clears the revocation recorded on an account's own row after its owner reauthorized it. A
1080+
* reauthorization can keep the old refresh token when the provider issues none, which the
1081+
* revocation's token fingerprint cannot tell from no reauthorization at all. One conditional
1082+
* statement with no Redis, so the sign-in path gains no dependency and most logins write nothing.
1083+
*/
1084+
export async function clearRecordedRevocation(accountId: string): Promise<void> {
1085+
await db
1086+
.update(account)
1087+
.set(CLEARED_REFRESH_REVOCATION)
1088+
.where(and(eq(account.id, accountId), isNotNull(account.refreshRevokedTokenHash)))
1089+
}
1090+
10781091
/**
10791092
* Clears every refresh failure recorded against an account after its owner reauthorized it: the
1080-
* revocation on its chain's rows and the Redis terminal-error flag. A reauthorization can keep
1081-
* the old refresh token when the provider issues none, which the revocation's token fingerprint
1082-
* cannot tell from no reauthorization at all.
1093+
* revocation on its chain's rows (every row of a Slack installation) and the Redis terminal-error
1094+
* flag.
10831095
*/
10841096
export async function clearOAuthRefreshFailure(accountId: string): Promise<void> {
10851097
const [row] = await db
@@ -1096,12 +1108,12 @@ export async function clearOAuthRefreshFailure(accountId: string): Promise<void>
10961108
? extractSlackTeamId(row.providerAccountId)
10971109
: null
10981110
await Promise.all([
1099-
db
1100-
.update(account)
1101-
.set(CLEARED_REFRESH_REVOCATION)
1102-
.where(
1103-
and(chainRowsFilter(accountId, slackTeamId), isNotNull(account.refreshRevokedTokenHash))
1104-
),
1111+
slackTeamId
1112+
? db
1113+
.update(account)
1114+
.set(CLEARED_REFRESH_REVOCATION)
1115+
.where(and(installationFilter(slackTeamId), isNotNull(account.refreshRevokedTokenHash)))
1116+
: clearRecordedRevocation(accountId),
11051117
clearOAuthRefreshDeadFlag(
11061118
refreshCoordinationScope(accountId, row.providerId, row.providerAccountId, row.idToken)
11071119
),

0 commit comments

Comments
 (0)