Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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`. [#1219](https://github.com/sourcebot-dev/sourcebot/pull/1219)
## [4.17.2] - 2026-05-16

### Added
Expand Down
93 changes: 58 additions & 35 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -259,20 +268,51 @@ 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.
const tokenSessionVersion = token.sessionVersion ?? 0;
if (!user && tokenSessionVersion !== 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({
Expand Down Expand Up @@ -313,35 +353,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<Session | null> => {
Comment thread
msukkari marked this conversation as resolved.
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();
});

/**
Expand Down
Loading