@@ -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,51 @@ 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+ const tokenSessionVersion = token . sessionVersion ?? 0 ;
307+ if ( ! user && tokenSessionVersion !== dbUser . sessionVersion ) {
308+ return null ;
309+ }
310+
311+ // Lazy migration of issuerUrl on accounts created before
312+ // the column was introduced in v4.15.4. The where clause
313+ // above scopes this to only accounts that still need it,
314+ // so the loop is a no-op once everyone is backfilled.
315+ for ( const account of dbUser . accounts ) {
276316 const issuerUrl = await getIssuerUrlForAccount ( account ) ;
277317 if ( issuerUrl ) {
278318 await __unsafePrisma . account . update ( {
@@ -313,35 +353,18 @@ const nextAuthResult = NextAuth({
313353export const { handlers, signIn, signOut } = nextAuthResult ;
314354
315355/**
316- * Wrapped session resolver that enforces JWT versioning at the auth layer .
356+ * Per-request memoized session resolver.
317357 *
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.
358+ * JWT validity (including the `sessionVersion` cross-check against the
359+ * database and the existence of the underlying `User` row) is enforced in
360+ * the `jwt` callback above. If that callback returns `null`, NextAuth's
361+ * core resolves the session to `null` here and also clears the cookie on
362+ * the response. We therefore only need to memoize the result within a
363+ * single request so that multiple `auth()` callers share the same answer
364+ * without re-running the upstream resolver.
323365 */
324366export 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 ;
367+ return nextAuthResult . auth ( ) ;
345368} ) ;
346369
347370/**
0 commit comments