Skip to content

Commit 4e554dc

Browse files
committed
fix(auth): revoke sessions and OAuth grants on password reset
1 parent 2d0ac79 commit 4e554dc

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
SIM_CLI_CLIENT_ID,
6868
} from '@/lib/auth/oauth-provider'
6969
import { bindOAuthIssuedResource, oauthResourcePlugin } from '@/lib/auth/oauth-resource'
70+
import { revokeUserOAuthGrants } from '@/lib/auth/oauth-token-family'
7071
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
7172
import { prepareSessionForCreation } from '@/lib/auth/session-hooks'
7273
import { clampExpiryForSession } from '@/lib/auth/session-policy'
@@ -988,7 +989,18 @@ export const auth = betterAuth({
988989
throw new Error(`Failed to send reset password email: ${result.message}`)
989990
}
990991
},
992+
/** Runs after `onPasswordReset`; cookie-cached sessions survive until `cookieCache.maxAge`. */
993+
revokeSessionsOnPasswordReset: true,
991994
onPasswordReset: async ({ user: resetUser }, request) => {
995+
try {
996+
await revokeUserOAuthGrants(resetUser.id)
997+
} catch (error) {
998+
// Throwing would skip the session revocation Better Auth runs after this hook.
999+
logger.error('[onPasswordReset] Failed to revoke OAuth grants', {
1000+
userId: resetUser.id,
1001+
error,
1002+
})
1003+
}
9921004
await recordPasswordResetAudit({ kind: 'completed', userId: resetUser.id, request })
9931005
},
9941006
},

‎apps/sim/lib/auth/lifecycle-audit.integration.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import {
33
account,
44
auditLog,
55
member,
6+
oauthAccessToken,
7+
oauthClient,
8+
oauthConsent,
9+
oauthTokenFamily,
610
organization,
711
session,
812
ssoProvider,
@@ -309,6 +313,84 @@ describe('authentication audit history in PostgreSQL', () => {
309313
).toHaveLength(0)
310314
})
311315

316+
it('ends every pre-reset session and OAuth token when a password reset completes', async () => {
317+
await login()
318+
const [current] = await db
319+
.select({ id: session.id })
320+
.from(session)
321+
.where(eq(session.userId, userId))
322+
const clientId = generateId()
323+
const familyId = generateId()
324+
const now = new Date()
325+
const expiresAt = new Date(now.getTime() + 86_400_000)
326+
await db.insert(oauthClient).values({
327+
id: clientId,
328+
clientId,
329+
name: 'Password reset OAuth fixture',
330+
public: true,
331+
tokenEndpointAuthMethod: 'none',
332+
grantTypes: ['authorization_code', 'refresh_token'],
333+
redirectUris: ['http://127.0.0.1/callback'],
334+
})
335+
try {
336+
await db.insert(oauthConsent).values({
337+
id: generateId(),
338+
clientId,
339+
userId,
340+
scopes: ['api:read'],
341+
createdAt: now,
342+
updatedAt: now,
343+
})
344+
await db.insert(oauthTokenFamily).values({
345+
id: familyId,
346+
clientId,
347+
userId,
348+
sessionId: current.id,
349+
createdAt: now,
350+
expiresAt,
351+
})
352+
await db.insert(oauthAccessToken).values({
353+
id: generateId(),
354+
token: generateId(),
355+
clientId,
356+
userId,
357+
scopes: ['api:read'],
358+
createdAt: now,
359+
expiresAt,
360+
})
361+
362+
const requested = await auth.handler(
363+
request('/request-password-reset', {
364+
email,
365+
redirectTo: 'http://localhost:3000/reset-password',
366+
})
367+
)
368+
expect(requested.status).toBe(200)
369+
const [resetToken] = await db
370+
.select({ identifier: verification.identifier })
371+
.from(verification)
372+
.where(eq(verification.value, userId))
373+
const completed = await auth.handler(
374+
request('/reset-password', {
375+
token: resetToken.identifier.replace('reset-password:', ''),
376+
newPassword: 'Audit-fixture-new-password-37!',
377+
})
378+
)
379+
expect(completed.status).toBe(200)
380+
381+
expect(await sessionCount()).toBe(0)
382+
expect(
383+
await db.select().from(oauthTokenFamily).where(eq(oauthTokenFamily.clientId, clientId))
384+
).toHaveLength(0)
385+
expect(
386+
await db.select().from(oauthAccessToken).where(eq(oauthAccessToken.clientId, clientId))
387+
).toHaveLength(0)
388+
await expectEvent('password.reset', userId)
389+
} finally {
390+
await db.delete(oauthClient).where(eq(oauthClient.clientId, clientId))
391+
}
392+
})
393+
312394
it('scopes an SSO admission refusal to the configured provider organization when the account belongs elsewhere', async () => {
313395
const providerOrganizationId = generateId()
314396
const providerId = generateId()

‎apps/sim/lib/auth/oauth-token-family.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,19 @@ export async function rotateOAuthRefreshToken(
493493
})
494494
}
495495

496+
/**
497+
* Revokes every refresh family and access token a user holds. Families outlive the session that
498+
* minted them (`session_id` is set null on delete), so deleting sessions alone leaves them
499+
* rotatable. Consents are kept, so a legitimate client re-authorizes without a new consent prompt.
500+
*/
501+
export async function revokeUserOAuthGrants(
502+
userId: string,
503+
database: OAuthDatabase = db
504+
): Promise<void> {
505+
await database.delete(oauthTokenFamily).where(eq(oauthTokenFamily.userId, userId))
506+
await database.delete(oauthAccessToken).where(eq(oauthAccessToken.userId, userId))
507+
}
508+
496509
/** Revokes one refresh family or one opaque access token. Unknown tokens are a successful no-op. */
497510
export async function revokeOAuthToken(
498511
input: { credentials: OAuthClientCredentials; token: string },

0 commit comments

Comments
 (0)