Skip to content

Commit 7243fdf

Browse files
feat(web): JWT session versioning and credential revocation on org removal (#1168)
* feat(web): JWT session versioning and credential revocation on org removal 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. * chore: add changelog entry for #1168
1 parent 1bac95b commit 7243fdf

6 files changed

Lines changed: 115 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- [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)
11+
- 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)
12+
- 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)
1213

1314
## [4.17.0] - 2026-04-30
1415

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;

packages/db/prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,11 @@ model User {
375375
oauthAuthCodes OAuthAuthorizationCode[]
376376
oauthRefreshTokens OAuthRefreshToken[]
377377
378+
/// Per-user JWT version. Incremented to invalidate every active session for
379+
/// this user on their next request. Compared against the `sessionVersion`
380+
/// claim baked into the JWT cookie at mint time.
381+
sessionVersion Int @default(0)
382+
378383
createdAt DateTime @default(now())
379384
updatedAt DateTime @updatedAt
380385

packages/web/src/__mocks__/prisma.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
4141
hashedPassword: null,
4242
emailVerified: null,
4343
image: null,
44+
sessionVersion: 0,
4445
accounts: [],
4546
}
4647

packages/web/src/auth.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'next-auth/jwt';
2-
import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth"
2+
import { cache } from "react";
3+
import NextAuth, { DefaultSession, Session, User as AuthJsUser } from "next-auth"
34
import Credentials from "next-auth/providers/credentials"
45
import EmailProvider from "next-auth/providers/nodemailer";
56
import { __unsafePrisma } from "@/prisma";
@@ -38,12 +39,17 @@ export type SessionUser = {
3839
declare module 'next-auth' {
3940
interface Session {
4041
user: SessionUser;
42+
sessionVersion?: number;
43+
}
44+
interface User {
45+
sessionVersion?: number;
4146
}
4247
}
4348

4449
declare module 'next-auth/jwt' {
4550
interface JWT {
4651
userId: string;
52+
sessionVersion?: number;
4753
}
4854
}
4955

@@ -113,6 +119,7 @@ export const getProviders = () => {
113119
const authJsUser: AuthJsUser = {
114120
id: newUser.id,
115121
email: newUser.email,
122+
sessionVersion: newUser.sessionVersion,
116123
}
117124

118125
onCreateUser({ user: authJsUser });
@@ -133,6 +140,7 @@ export const getProviders = () => {
133140
email: user.email,
134141
name: user.name ?? undefined,
135142
image: user.image ?? undefined,
143+
sessionVersion: user.sessionVersion,
136144
};
137145
}
138146
}
@@ -143,7 +151,7 @@ export const getProviders = () => {
143151
return providers;
144152
}
145153

146-
export const { handlers, signIn, signOut, auth } = NextAuth({
154+
const nextAuthResult = NextAuth({
147155
secret: env.AUTH_SECRET,
148156
adapter: EncryptedPrismaAdapter(__unsafePrisma),
149157
session: {
@@ -248,6 +256,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
248256
// Cache the userId in the JWT for later use.
249257
if (user) {
250258
token.userId = user.id;
259+
token.sessionVersion = user.sessionVersion ?? 0;
251260
}
252261

253262
// @note The following performs a lazy migration of the issuerUrl
@@ -288,6 +297,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
288297
// Propagate the userId to the session.
289298
id: token.userId,
290299
}
300+
session.sessionVersion = token.sessionVersion;
291301

292302
return session;
293303
},
@@ -300,6 +310,40 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
300310
}
301311
});
302312

313+
export const { handlers, signIn, signOut } = nextAuthResult;
314+
315+
/**
316+
* Wrapped session resolver that enforces JWT versioning at the auth layer.
317+
*
318+
* Every JWT cookie carries the `sessionVersion` it was minted with. This
319+
* wrapper compares it against the user's current `sessionVersion` in the
320+
* database; if the user's version has been bumped (e.g., they were removed
321+
* from the org), we return null so every caller of `auth()` sees the
322+
* session as logged out.
323+
*/
324+
export const auth = cache(async (): Promise<Session | null> => {
325+
const session = await nextAuthResult.auth();
326+
if (!session) {
327+
return null;
328+
}
329+
330+
const dbUser = await __unsafePrisma.user.findUnique({
331+
where: { id: session.user.id },
332+
select: { sessionVersion: true },
333+
});
334+
335+
if (!dbUser) {
336+
return null;
337+
}
338+
339+
const tokenVersion = session.sessionVersion ?? 0;
340+
if (tokenVersion !== dbUser.sessionVersion) {
341+
return null;
342+
}
343+
344+
return session;
345+
});
346+
303347
/**
304348
* Returns the issuer URL for a given auth.js account
305349
*/

packages/web/src/features/userManagement/actions.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
4545
}
4646
}
4747

48+
await invalidateAllSessionsForUser(tx, memberId);
49+
await revokeUserOAuthTokens(tx, memberId);
50+
await revokeUserApiKeysInOrg(tx, memberId, org.id);
51+
4852
await tx.userToOrg.delete({
4953
where: {
5054
orgId_userId: {
@@ -95,6 +99,10 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
9599
}
96100
}
97101

102+
await invalidateAllSessionsForUser(tx, user.id);
103+
await revokeUserOAuthTokens(tx, user.id);
104+
await revokeUserApiKeysInOrg(tx, user.id, org.id);
105+
98106
await tx.userToOrg.delete({
99107
where: {
100108
orgId_userId: {
@@ -125,3 +133,54 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
125133
success: true,
126134
}
127135
}));
136+
137+
/**
138+
* Invalidates every active JWT cookie for the given user by incrementing
139+
* their `sessionVersion`. The next request from any of their active
140+
* sessions will compare the cookie's baked-in version against the
141+
* (now-bumped) value on the User row, fail, and be treated as logged out.
142+
*/
143+
const invalidateAllSessionsForUser = async (
144+
prisma: Prisma.TransactionClient,
145+
userId: string,
146+
): Promise<void> => {
147+
await prisma.user.update({
148+
where: { id: userId },
149+
data: { sessionVersion: { increment: 1 } },
150+
});
151+
};
152+
153+
const revokeUserApiKeysInOrg = async (
154+
prisma: Prisma.TransactionClient,
155+
userId: string,
156+
orgId: number,
157+
): Promise<void> => {
158+
await prisma.apiKey.deleteMany({
159+
where: {
160+
createdById: userId,
161+
orgId,
162+
}
163+
});
164+
};
165+
166+
const revokeUserOAuthTokens = async (
167+
prisma: Prisma.TransactionClient,
168+
userId: string,
169+
): Promise<void> => {
170+
await prisma.oAuthToken.deleteMany({
171+
where: {
172+
userId
173+
}
174+
});
175+
await prisma.oAuthRefreshToken.deleteMany({
176+
where: {
177+
userId
178+
}
179+
});
180+
await prisma.oAuthAuthorizationCode.deleteMany({
181+
where: {
182+
userId
183+
}
184+
});
185+
};
186+

0 commit comments

Comments
 (0)