Skip to content

Commit 9ea7a0d

Browse files
msukkariclaude
andcommitted
fix(web): propagate session invalidation to /api/auth/session
NextAuth's stock /api/auth/session route decodes the JWT and returns the user record to the client without consulting Sourcebot's database. Three release-acceptance scenarios depended on the endpoint reporting "logged out" after invalidation: - AUTHN-004: replayed cookie after signout. - AUTHN-006: replayed cookie after user is removed from org. - AUTHN-011: replayed cookie after user is deleted. The wrapped auth() at the application layer was already cross-checking sessionVersion and the User row's existence on every withAuth call, so protected resources correctly returned 401 in all three cases. But because /api/auth/session never went through that wrapper, the endpoint continued to leak a positive "you're signed in" answer. Two changes close the gap: 1. events.signOut now increments User.sessionVersion before writing the audit log, mirroring invalidateAllSessionsForUser. This is what AUTHN-004 specifically needed. 2. The jwt callback now performs a single User.findUnique on every non-login request, returning null when the row is missing (AUTHN-011) or sessionVersion has been bumped since the JWT was minted (AUTHN-004 / AUTHN-006). When the callback returns null, @auth/core (a) reports the session as logged out from /api/auth/session, (b) skips the rolling-session cookie refresh, and (c) clears the cookie from the browser via Set-Cookie. The same query also batches the existing lazy issuerUrl migration, so the new check costs one User.findUnique per request (with the existing accounts findMany folded into the same query, net query count is unchanged or lower). With the jwt callback now responsible for the invalidation check, the duplicate cross-check that lived in the wrapped auth() resolver has been removed. The wrapper is now just a React cache() boundary around nextAuthResult.auth(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3851c66 commit 9ea7a0d

2 files changed

Lines changed: 58 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Fixed issue where repo permissions could go stale when authentication or token refresh related errors occured. [#1215](https://github.com/sourcebot-dev/sourcebot/pull/1215)
1212
- [EE] Fixed issue where repo permissions could go stale when an upstream endpoint returned HTTP 410 Gone (e.g. Bitbucket Cloud's CHANGE-2770). [#1216](https://github.com/sourcebot-dev/sourcebot/pull/1216)
1313
- [EE] Fixed Bitbucket Cloud account-driven permission sync after Atlassian's CHANGE-2770 removed `GET /2.0/user/permissions/repositories`. [#1217](https://github.com/sourcebot-dev/sourcebot/pull/1217)
14+
- Fixed issue where session invalidation (signout, user deletion, removal from org) was not reflected by `/api/auth/session`, causing UI clients to mistakenly believe a user was still signed in.
1415

1516
## [4.17.2] - 2026-05-16
1617

packages/web/src/auth.ts

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@ const nextAuthResult = NextAuth({
214214
signOut: async (message) => {
215215
const token = message as { token: { userId: string } | null };
216216
if (token?.token?.userId) {
217+
// Bump sessionVersion so any JWT minted before this signout
218+
// is treated as invalid by the jwt callback's DB cross-check
219+
// on its next request, even if the cookie value was captured
220+
// and is being replayed.
221+
await __unsafePrisma.user.update({
222+
where: { id: token.token.userId },
223+
data: { sessionVersion: { increment: 1 } },
224+
});
225+
217226
await auditService.createAudit({
218227
action: "user.signed_out",
219228
actor: {
@@ -259,20 +268,50 @@ const nextAuthResult = NextAuth({
259268
token.sessionVersion = user.sessionVersion ?? 0;
260269
}
261270

262-
// @note The following performs a lazy migration of the issuerUrl
263-
// in the user's accounts. The issuerUrl was introduced in v4.15.4
264-
// and will not be present for accounts created prior to this version.
265-
//
266-
// @see https://github.com/sourcebot-dev/sourcebot/pull/993
267271
if (token.userId) {
268-
const accountsWithoutIssuerUrl = await __unsafePrisma.account.findMany({
272+
// Single query: fetch the user's current sessionVersion for
273+
// the cross-check below, plus any accounts that still need
274+
// the issuerUrl lazy migration.
275+
//
276+
// @see https://github.com/sourcebot-dev/sourcebot/pull/993
277+
const dbUser = await __unsafePrisma.user.findUnique({
269278
where: {
270-
userId: token.userId,
271-
issuerUrl: null,
279+
id: token.userId as string,
280+
},
281+
select: {
282+
sessionVersion: true,
283+
accounts: {
284+
where: {
285+
issuerUrl: null,
286+
},
287+
},
272288
},
273289
});
274290

275-
for (const account of accountsWithoutIssuerUrl) {
291+
// The user row was removed (e.g., deleted via /api/ee/user
292+
// or org-removal cascade). Treat the JWT as invalid so
293+
// /api/auth/session reports logged-out and @auth/core clears
294+
// the cookie from the browser.
295+
if (!dbUser) {
296+
return null;
297+
}
298+
299+
// On every non-login request, cross-check the JWT's
300+
// sessionVersion against the user's current sessionVersion in
301+
// the database. A mismatch means the user signed out, was
302+
// removed from the org, or their sessions were otherwise
303+
// invalidated since the JWT was minted. Returning null here
304+
// is what makes invalidation visible at /api/auth/session,
305+
// not just at withAuth-gated endpoints.
306+
if (!user && (token.sessionVersion ?? 0) !== dbUser.sessionVersion) {
307+
return null;
308+
}
309+
310+
// Lazy migration of issuerUrl on accounts created before
311+
// the column was introduced in v4.15.4. The where clause
312+
// above scopes this to only accounts that still need it,
313+
// so the loop is a no-op once everyone is backfilled.
314+
for (const account of dbUser.accounts) {
276315
const issuerUrl = await getIssuerUrlForAccount(account);
277316
if (issuerUrl) {
278317
await __unsafePrisma.account.update({
@@ -313,35 +352,18 @@ const nextAuthResult = NextAuth({
313352
export const { handlers, signIn, signOut } = nextAuthResult;
314353

315354
/**
316-
* Wrapped session resolver that enforces JWT versioning at the auth layer.
355+
* Per-request memoized session resolver.
317356
*
318-
* Every JWT cookie carries the `sessionVersion` it was minted with. This
319-
* wrapper compares it against the user's current `sessionVersion` in the
320-
* database; if the user's version has been bumped (e.g., they were removed
321-
* from the org), we return null so every caller of `auth()` sees the
322-
* session as logged out.
357+
* JWT validity (including the `sessionVersion` cross-check against the
358+
* database and the existence of the underlying `User` row) is enforced in
359+
* the `jwt` callback above. If that callback returns `null`, NextAuth's
360+
* core resolves the session to `null` here and also clears the cookie on
361+
* the response. We therefore only need to memoize the result within a
362+
* single request so that multiple `auth()` callers share the same answer
363+
* without re-running the upstream resolver.
323364
*/
324365
export const auth = cache(async (): Promise<Session | null> => {
325-
const session = await nextAuthResult.auth();
326-
if (!session) {
327-
return null;
328-
}
329-
330-
const dbUser = await __unsafePrisma.user.findUnique({
331-
where: { id: session.user.id },
332-
select: { sessionVersion: true },
333-
});
334-
335-
if (!dbUser) {
336-
return null;
337-
}
338-
339-
const tokenVersion = session.sessionVersion ?? 0;
340-
if (tokenVersion !== dbUser.sessionVersion) {
341-
return null;
342-
}
343-
344-
return session;
366+
return nextAuthResult.auth();
345367
});
346368

347369
/**

0 commit comments

Comments
 (0)