From 9ea7a0d3cefb8123430deda3ad21602e50ed0702 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 21 May 2026 18:54:55 -0700 Subject: [PATCH 1/3] 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) --- CHANGELOG.md | 1 + packages/web/src/auth.ts | 92 +++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5afa031d3..8eb35a034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - [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) - [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) +- 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. ## [4.17.2] - 2026-05-16 diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 467ab6fa1..4f3bf8a12 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -214,6 +214,15 @@ const nextAuthResult = NextAuth({ signOut: async (message) => { const token = message as { token: { userId: string } | null }; if (token?.token?.userId) { + // Bump sessionVersion so any JWT minted before this signout + // is treated as invalid by the jwt callback's DB cross-check + // on its next request, even if the cookie value was captured + // and is being replayed. + await __unsafePrisma.user.update({ + where: { id: token.token.userId }, + data: { sessionVersion: { increment: 1 } }, + }); + await auditService.createAudit({ action: "user.signed_out", actor: { @@ -259,20 +268,50 @@ const nextAuthResult = NextAuth({ token.sessionVersion = user.sessionVersion ?? 0; } - // @note The following performs a lazy migration of the issuerUrl - // in the user's accounts. The issuerUrl was introduced in v4.15.4 - // and will not be present for accounts created prior to this version. - // - // @see https://github.com/sourcebot-dev/sourcebot/pull/993 if (token.userId) { - const accountsWithoutIssuerUrl = await __unsafePrisma.account.findMany({ + // Single query: fetch the user's current sessionVersion for + // the cross-check below, plus any accounts that still need + // the issuerUrl lazy migration. + // + // @see https://github.com/sourcebot-dev/sourcebot/pull/993 + const dbUser = await __unsafePrisma.user.findUnique({ where: { - userId: token.userId, - issuerUrl: null, + id: token.userId as string, + }, + select: { + sessionVersion: true, + accounts: { + where: { + issuerUrl: null, + }, + }, }, }); - for (const account of accountsWithoutIssuerUrl) { + // The user row was removed (e.g., deleted via /api/ee/user + // or org-removal cascade). Treat the JWT as invalid so + // /api/auth/session reports logged-out and @auth/core clears + // the cookie from the browser. + if (!dbUser) { + return null; + } + + // On every non-login request, cross-check the JWT's + // sessionVersion against the user's current sessionVersion in + // the database. A mismatch means the user signed out, was + // removed from the org, or their sessions were otherwise + // invalidated since the JWT was minted. Returning null here + // is what makes invalidation visible at /api/auth/session, + // not just at withAuth-gated endpoints. + if (!user && (token.sessionVersion ?? 0) !== dbUser.sessionVersion) { + return null; + } + + // Lazy migration of issuerUrl on accounts created before + // the column was introduced in v4.15.4. The where clause + // above scopes this to only accounts that still need it, + // so the loop is a no-op once everyone is backfilled. + for (const account of dbUser.accounts) { const issuerUrl = await getIssuerUrlForAccount(account); if (issuerUrl) { await __unsafePrisma.account.update({ @@ -313,35 +352,18 @@ const nextAuthResult = NextAuth({ export const { handlers, signIn, signOut } = nextAuthResult; /** - * Wrapped session resolver that enforces JWT versioning at the auth layer. + * Per-request memoized session resolver. * - * Every JWT cookie carries the `sessionVersion` it was minted with. This - * wrapper compares it against the user's current `sessionVersion` in the - * database; if the user's version has been bumped (e.g., they were removed - * from the org), we return null so every caller of `auth()` sees the - * session as logged out. + * JWT validity (including the `sessionVersion` cross-check against the + * database and the existence of the underlying `User` row) is enforced in + * the `jwt` callback above. If that callback returns `null`, NextAuth's + * core resolves the session to `null` here and also clears the cookie on + * the response. We therefore only need to memoize the result within a + * single request so that multiple `auth()` callers share the same answer + * without re-running the upstream resolver. */ export const auth = cache(async (): Promise => { - const session = await nextAuthResult.auth(); - if (!session) { - return null; - } - - const dbUser = await __unsafePrisma.user.findUnique({ - where: { id: session.user.id }, - select: { sessionVersion: true }, - }); - - if (!dbUser) { - return null; - } - - const tokenVersion = session.sessionVersion ?? 0; - if (tokenVersion !== dbUser.sessionVersion) { - return null; - } - - return session; + return nextAuthResult.auth(); }); /** From de019dfdbfa9d458179ebb84ac72ac2faa8d3bc3 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 21 May 2026 18:55:29 -0700 Subject: [PATCH 2/3] chore: link CHANGELOG entry to #1219 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb35a034..5789f2bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - [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) - [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) -- 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. - +- Fixed issue where session invalidation (signout, user deletion, removal from org) was not reflected by `/api/auth/session`. [#1219](https://github.com/sourcebot-dev/sourcebot/pull/1219) ## [4.17.2] - 2026-05-16 ### Added From c422143f5ec1a374ebb0b9f8286dfaf28538ed93 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 21 May 2026 19:51:09 -0700 Subject: [PATCH 3/3] chore: hoist token.sessionVersion ?? 0 into a named local Slightly easier to read the cross-check condition when the JWT-side value isn't sharing a line with the && and the inequality. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 4f3bf8a12..3c89d1dcc 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -303,7 +303,8 @@ const nextAuthResult = NextAuth({ // invalidated since the JWT was minted. Returning null here // is what makes invalidation visible at /api/auth/session, // not just at withAuth-gated endpoints. - if (!user && (token.sessionVersion ?? 0) !== dbUser.sessionVersion) { + const tokenSessionVersion = token.sessionVersion ?? 0; + if (!user && tokenSessionVersion !== dbUser.sessionVersion) { return null; }