diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d618948c..566550f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- [EE] Added three new audit actions covering the full org membership lifecycle: `org.member_added`, `org.member_removed`, and `org.member_left`. [#1165](https://github.com/sourcebot-dev/sourcebot/pull/1165) +- Added three new audit actions covering the full org membership lifecycle: `org.member_added`, `org.member_removed`, and `org.member_left`. [#1165](https://github.com/sourcebot-dev/sourcebot/pull/1165) +- 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 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 b333430a2..99b46268c 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -45,6 +45,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: { @@ -95,6 +99,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: { @@ -125,3 +133,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 + } + }); +}; +