Skip to content

Commit 278ccee

Browse files
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.
1 parent ff41d83 commit 278ccee

5 files changed

Lines changed: 113 additions & 2 deletions

File tree

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
@@ -42,6 +42,10 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
4242
}
4343
}
4444

45+
await invalidateAllSessionsForUser(tx, memberId);
46+
await revokeUserOAuthTokens(tx, memberId);
47+
await revokeUserApiKeysInOrg(tx, memberId, org.id);
48+
4549
await tx.userToOrg.delete({
4650
where: {
4751
orgId_userId: {
@@ -82,6 +86,10 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
8286
}
8387
}
8488

89+
await invalidateAllSessionsForUser(tx, user.id);
90+
await revokeUserOAuthTokens(tx, user.id);
91+
await revokeUserApiKeysInOrg(tx, user.id, org.id);
92+
8593
await tx.userToOrg.delete({
8694
where: {
8795
orgId_userId: {
@@ -102,3 +110,54 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
102110
success: true,
103111
}
104112
}));
113+
114+
/**
115+
* Invalidates every active JWT cookie for the given user by incrementing
116+
* their `sessionVersion`. The next request from any of their active
117+
* sessions will compare the cookie's baked-in version against the
118+
* (now-bumped) value on the User row, fail, and be treated as logged out.
119+
*/
120+
const invalidateAllSessionsForUser = async (
121+
prisma: Prisma.TransactionClient,
122+
userId: string,
123+
): Promise<void> => {
124+
await prisma.user.update({
125+
where: { id: userId },
126+
data: { sessionVersion: { increment: 1 } },
127+
});
128+
};
129+
130+
const revokeUserApiKeysInOrg = async (
131+
prisma: Prisma.TransactionClient,
132+
userId: string,
133+
orgId: number,
134+
): Promise<void> => {
135+
await prisma.apiKey.deleteMany({
136+
where: {
137+
createdById: userId,
138+
orgId,
139+
}
140+
});
141+
};
142+
143+
const revokeUserOAuthTokens = async (
144+
prisma: Prisma.TransactionClient,
145+
userId: string,
146+
): Promise<void> => {
147+
await prisma.oAuthToken.deleteMany({
148+
where: {
149+
userId
150+
}
151+
});
152+
await prisma.oAuthRefreshToken.deleteMany({
153+
where: {
154+
userId
155+
}
156+
});
157+
await prisma.oAuthAuthorizationCode.deleteMany({
158+
where: {
159+
userId
160+
}
161+
});
162+
};
163+

0 commit comments

Comments
 (0)