Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
brendan-kellam marked this conversation as resolved.

## [4.17.0] - 2026-04-30

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
5 changes: 5 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
hashedPassword: null,
emailVerified: null,
image: null,
sessionVersion: 0,
accounts: [],
}

Expand Down
48 changes: 46 additions & 2 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -113,6 +119,7 @@ export const getProviders = () => {
const authJsUser: AuthJsUser = {
id: newUser.id,
email: newUser.email,
sessionVersion: newUser.sessionVersion,
}

onCreateUser({ user: authJsUser });
Expand All @@ -133,6 +140,7 @@ export const getProviders = () => {
email: user.email,
name: user.name ?? undefined,
image: user.image ?? undefined,
sessionVersion: user.sessionVersion,
};
}
}
Expand All @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
},
Expand All @@ -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<Session | null> => {
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
*/
Expand Down
59 changes: 59 additions & 0 deletions packages/web/src/features/userManagement/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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<void> => {
await prisma.user.update({
where: { id: userId },
data: { sessionVersion: { increment: 1 } },
});
};

const revokeUserApiKeysInOrg = async (
prisma: Prisma.TransactionClient,
userId: string,
orgId: number,
): Promise<void> => {
await prisma.apiKey.deleteMany({
where: {
createdById: userId,
orgId,
}
});
};

const revokeUserOAuthTokens = async (
prisma: Prisma.TransactionClient,
userId: string,
): Promise<void> => {
await prisma.oAuthToken.deleteMany({
where: {
userId
}
});
await prisma.oAuthRefreshToken.deleteMany({
where: {
userId
}
});
await prisma.oAuthAuthorizationCode.deleteMany({
where: {
userId
}
});
Comment thread
brendan-kellam marked this conversation as resolved.
};

Loading