From 278ccee6e040bf36da76519e63404fad48c26d53 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 1 May 2026 17:00:12 -0700 Subject: [PATCH 1/2] feat(web): JWT session versioning and credential revocation on org removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-user `sessionVersion` integer to the `User` model. The version is baked into every newly-minted JWT cookie via the `jwt` callback, copied onto the session via the `session` callback, and verified on every read by a wrapped `auth()` function that compares the cookie's claim against the current DB value — mismatch returns null, treating the session as logged out on the very next request. Backwards compatible: pre-migration cookies have no claim and fall back to 0, which matches the default User.sessionVersion of 0, so existing sessions keep working until something explicitly bumps the user's version. The `auth()` wrapper is memoized per-request via React `cache()` so the extra DB read happens at most once per request even though `auth()` is called from many places (layout, page, withAuth, getAuthenticatedUser). `removeMemberFromOrg` and `leaveOrg` now run three credential-revocation helpers inside the existing serializable transaction: - `invalidateAllSessionsForUser` — bumps the version, killing every active JWT cookie for the user on their next request. - `revokeUserOAuthTokens` — deletes their `OAuthToken`, `OAuthRefreshToken`, and `OAuthAuthorizationCode` rows. Not org-scoped because OAuthClient has no `orgId`. - `revokeUserApiKeysInOrg` — deletes their `ApiKey` rows scoped to the current org (ApiKey.orgId). Net effect: when an admin removes a member (or a member leaves), the user's JWT cookie, personal API keys for that org, and OAuth tokens all stop working atomically. A failed transaction rolls back all four changes. --- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 5 ++ packages/web/src/__mocks__/prisma.ts | 1 + packages/web/src/auth.ts | 48 ++++++++++++++- .../src/features/userManagement/actions.ts | 59 +++++++++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/db/prisma/migrations/20260501170139_add_user_session_version/migration.sql diff --git a/packages/db/prisma/migrations/20260501170139_add_user_session_version/migration.sql b/packages/db/prisma/migrations/20260501170139_add_user_session_version/migration.sql new file mode 100644 index 000000000..dd58960e8 --- /dev/null +++ b/packages/db/prisma/migrations/20260501170139_add_user_session_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3a96eea6e..c81bfd62d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -375,6 +375,11 @@ model User { oauthAuthCodes OAuthAuthorizationCode[] oauthRefreshTokens OAuthRefreshToken[] + /// Per-user JWT version. Incremented to invalidate every active session for + /// this user on their next request. Compared against the `sessionVersion` + /// claim baked into the JWT cookie at mint time. + sessionVersion Int @default(0) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index a2d11360e..2a53ca69f 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -41,6 +41,7 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = { hashedPassword: null, emailVerified: null, image: null, + sessionVersion: 0, accounts: [], } diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 9b5d93d90..467ab6fa1 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,5 +1,6 @@ import 'next-auth/jwt'; -import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth" +import { cache } from "react"; +import NextAuth, { DefaultSession, Session, User as AuthJsUser } from "next-auth" import Credentials from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/nodemailer"; import { __unsafePrisma } from "@/prisma"; @@ -38,12 +39,17 @@ export type SessionUser = { declare module 'next-auth' { interface Session { user: SessionUser; + sessionVersion?: number; + } + interface User { + sessionVersion?: number; } } declare module 'next-auth/jwt' { interface JWT { userId: string; + sessionVersion?: number; } } @@ -113,6 +119,7 @@ export const getProviders = () => { const authJsUser: AuthJsUser = { id: newUser.id, email: newUser.email, + sessionVersion: newUser.sessionVersion, } onCreateUser({ user: authJsUser }); @@ -133,6 +140,7 @@ export const getProviders = () => { email: user.email, name: user.name ?? undefined, image: user.image ?? undefined, + sessionVersion: user.sessionVersion, }; } } @@ -143,7 +151,7 @@ export const getProviders = () => { return providers; } -export const { handlers, signIn, signOut, auth } = NextAuth({ +const nextAuthResult = NextAuth({ secret: env.AUTH_SECRET, adapter: EncryptedPrismaAdapter(__unsafePrisma), session: { @@ -248,6 +256,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Cache the userId in the JWT for later use. if (user) { token.userId = user.id; + token.sessionVersion = user.sessionVersion ?? 0; } // @note The following performs a lazy migration of the issuerUrl @@ -288,6 +297,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Propagate the userId to the session. id: token.userId, } + session.sessionVersion = token.sessionVersion; return session; }, @@ -300,6 +310,40 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } }); +export const { handlers, signIn, signOut } = nextAuthResult; + +/** + * Wrapped session resolver that enforces JWT versioning at the auth layer. + * + * 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. + */ +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; +}); + /** * Returns the issuer URL for a given auth.js account */ diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 1b82b7cd0..30b954a28 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -42,6 +42,10 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success: } } + await invalidateAllSessionsForUser(tx, memberId); + await revokeUserOAuthTokens(tx, memberId); + await revokeUserApiKeysInOrg(tx, memberId, org.id); + await tx.userToOrg.delete({ where: { orgId_userId: { @@ -82,6 +86,10 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> = } } + await invalidateAllSessionsForUser(tx, user.id); + await revokeUserOAuthTokens(tx, user.id); + await revokeUserApiKeysInOrg(tx, user.id, org.id); + await tx.userToOrg.delete({ where: { orgId_userId: { @@ -102,3 +110,54 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> = success: true, } })); + +/** + * Invalidates every active JWT cookie for the given user by incrementing + * their `sessionVersion`. The next request from any of their active + * sessions will compare the cookie's baked-in version against the + * (now-bumped) value on the User row, fail, and be treated as logged out. + */ +const invalidateAllSessionsForUser = async ( + prisma: Prisma.TransactionClient, + userId: string, +): Promise => { + await prisma.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + }); +}; + +const revokeUserApiKeysInOrg = async ( + prisma: Prisma.TransactionClient, + userId: string, + orgId: number, +): Promise => { + await prisma.apiKey.deleteMany({ + where: { + createdById: userId, + orgId, + } + }); +}; + +const revokeUserOAuthTokens = async ( + prisma: Prisma.TransactionClient, + userId: string, +): Promise => { + await prisma.oAuthToken.deleteMany({ + where: { + userId + } + }); + await prisma.oAuthRefreshToken.deleteMany({ + where: { + userId + } + }); + await prisma.oAuthAuthorizationCode.deleteMany({ + where: { + userId + } + }); +}; + From 5b9b3ed3e2de85ac89624f202f82965fcca5a3fa Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 1 May 2026 17:02:07 -0700 Subject: [PATCH 2/2] chore: add changelog entry for #1168 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dfd5050..4b0b061e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added per-user JWT session versioning so admin-driven member removals (and voluntary leaves) invalidate the removed user's active JWT cookies, personal API keys, and OAuth tokens atomically on their next request. [#1168](https://github.com/sourcebot-dev/sourcebot/pull/1168) + ## [4.17.0] - 2026-04-30 ### Added