Compare commits

...

1 Commits

Author SHA1 Message Date
Matti Nannt
3c5ad2cdb8 fix: revoke auth cookies on logout
Create a server-side session record for JWT logins and validate it on each request so logout can revoke stolen cookies immediately.
2025-12-12 11:00:18 +01:00
8 changed files with 334 additions and 17 deletions

View File

@@ -0,0 +1,87 @@
"use server";
import { getServerSession } from "next-auth";
import { getToken } from "next-auth/jwt";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { NEXTAUTH_SECRET } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
/**
* Invalidates the current user's session by deleting it from the database.
* This is called during logout to ensure JWT tokens cannot be reused.
*/
export async function invalidateCurrentSession() {
try {
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const token = await getToken({
req: { headers: { cookie: cookieHeader } } as any,
secret: NEXTAUTH_SECRET,
});
const sessionToken = (token as any)?.sessionToken as string | undefined;
if (sessionToken) {
await prisma.session.deleteMany({ where: { sessionToken } });
logger.info({ sessionToken }, "Invalidated current session by sessionToken");
return;
}
// Fallback: if we can't decode the token, invalidate all sessions for the current user.
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
logger.warn("No active session to invalidate");
return;
}
const result = await prisma.session.deleteMany({ where: { userId: session.user.id } });
logger.info({ userId: session.user.id, sessionsDeleted: result.count }, "Invalidated all user sessions");
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to invalidate current session"
);
// Don't throw - we don't want to block logout if session deletion fails
}
}
/**
* Invalidates all sessions for a given user.
* Useful for "logout from all devices" functionality.
*
* @param userId - The ID of the user whose sessions should be invalidated
* @throws Error if the operation fails
*/
export async function invalidateAllUserSessions(userId: string) {
try {
const result = await prisma.session.deleteMany({
where: { userId },
});
logger.info(
{
userId,
sessionsDeleted: result.count,
},
"Invalidated all user sessions"
);
return result.count;
} catch (error) {
logger.error(
{
userId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to invalidate user sessions"
);
throw error;
}
}

View File

@@ -1,6 +1,7 @@
import { signOut } from "next-auth/react";
import { logger } from "@formbricks/logger";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { invalidateCurrentSession } from "@/modules/auth/actions/invalidate-sessions";
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
interface UseSignOutOptions {
@@ -44,11 +45,19 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
}
}
// Invalidate session in database before clearing JWT
try {
await invalidateCurrentSession();
} catch (error) {
// Don't block signOut if session invalidation fails
logger.error("Failed to invalidate session:", error);
}
if (options?.clearEnvironmentId) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
}
// Call NextAuth signOut
// Call NextAuth signOut (clears JWT cookie)
return await signOut({
redirect: options?.redirect,
callbackUrl: options?.callbackUrl,

View File

@@ -1,6 +1,9 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { Account, NextAuthOptions } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
import crypto from "node:crypto";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TUser } from "@formbricks/types/user";
@@ -13,7 +16,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -31,6 +34,8 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
// Note: Adapter is only used for OAuth providers, not for CredentialsProvider
adapter: PrismaAdapter(prisma) as Adapter,
providers: [
CredentialsProvider({
id: "credentials",
@@ -310,30 +315,120 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
// Use JWT strategy for CredentialsProvider compatibility
// Database sessions via adapter work for OAuth providers only
strategy: "jwt",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
async jwt({ token, user }) {
// IMPORTANT:
// This code runs inside `/api/auth/[...nextauth]/route.ts` which wraps and rethrows
// callback errors. So we must NEVER throw here; instead, return an empty token to
// force `getServerSession()` to return null (unauthenticated).
if (!existingUser) {
return token;
// On sign in (when user object is available), create a server-side session record
// and bind it to the JWT as `sessionToken`. This enables revocation on logout.
if (user) {
token.email = user.email;
token.profile = { id: user.id };
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
if (user && !token.sessionToken) {
try {
const sessionToken = crypto.randomUUID();
const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await prisma.session.create({
data: {
sessionToken,
userId: user.id,
expires,
},
});
token.sessionToken = sessionToken;
} catch (err) {
logger.error({ err }, "Failed to create server-side session record");
return {};
}
}
// Validate that the server-side session record still exists (revocation).
if (token.sessionToken) {
try {
const session = await prisma.session.findUnique({
where: { sessionToken: token.sessionToken as string },
select: { expires: true },
});
if (!session || session.expires < new Date()) {
return {};
}
} catch (err) {
logger.error({ err }, "Failed to validate server-side session record");
return {};
}
}
// Attach latest user state (e.g., isActive) to token.
const userId = (token.profile as { id?: string } | undefined)?.id;
if (!userId) return {};
// Backfill sessionToken for existing JWTs (e.g. after deploying this change),
// so that logout revocation works without requiring an explicit re-login.
if (!token.sessionToken) {
try {
const sessionToken = crypto.randomUUID();
const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await prisma.session.create({
data: {
sessionToken,
userId,
expires,
},
});
token.sessionToken = sessionToken;
} catch (err) {
logger.error({ err }, "Failed to backfill server-side session record");
return {};
}
}
try {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!existingUser) return {};
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
} catch (err) {
logger.error({ err }, "Failed to load user for session token");
return {};
}
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
// If token was invalidated (empty token), treat as unauthenticated.
const profile = token.profile as { id?: string } | undefined;
if (!profile?.id) {
// Make downstream checks like `if (!session?.user)` work reliably.
// (Default NextAuth session type allows `user` to be undefined.)
session.user = undefined;
return session;
}
const sessionUser = session.user ?? ({} as NonNullable<typeof session.user>);
sessionUser.id = profile.id;
(sessionUser as { id: string; isActive: boolean }).isActive = token.isActive !== false;
session.user = sessionUser;
return session;
},
async signIn({ user, account }: { user: TUser; account: Account }) {

View File

@@ -19,6 +19,7 @@
"i18n:generate": "npx lingo.dev@latest i18n"
},
"dependencies": {
"@auth/prisma-adapter": "2.11.1",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",

10
apps/web/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user?: {
id: string;
isActive: boolean;
} & DefaultSession["user"];
}
}

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "public"."sessions" (
"id" TEXT NOT NULL,
"session_token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."verification_tokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_session_token_key" ON "public"."sessions"("session_token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "public"."sessions"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "public"."verification_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "public"."verification_tokens"("identifier", "token");
-- AddForeignKey
ALTER TABLE "public"."sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -832,6 +832,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
@@ -847,6 +848,34 @@ model User {
@@index([email])
}
/// Represents an active user session for authentication.
/// Used by NextAuth for database session strategy.
///
/// @property sessionToken - Unique token identifying the session
/// @property userId - The user this session belongs to
/// @property expires - When the session expires
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
/// Stores verification tokens for email verification flows.
/// Used by NextAuth for magic link authentication.
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
/// Defines a segment of contacts based on attributes.
/// Used for targeting surveys to specific user groups.
///

55
pnpm-lock.yaml generated
View File

@@ -108,6 +108,9 @@ importers:
apps/web:
dependencies:
'@auth/prisma-adapter':
specifier: 2.11.1
version: 2.11.1(@prisma/client@6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3))(nodemailer@7.0.11)
'@aws-sdk/client-s3':
specifier: 3.879.0
version: 3.879.0
@@ -922,6 +925,25 @@ packages:
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
'@auth/core@0.41.1':
resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^7.0.7
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@auth/prisma-adapter@2.11.1':
resolution: {integrity: sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6'
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -8237,6 +8259,14 @@ packages:
peerDependencies:
preact: '>=10'
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
preact@10.26.6:
resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
@@ -10062,6 +10092,25 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@auth/core@0.41.1(nodemailer@7.0.11)':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.0.11
oauth4webapi: 3.8.2
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 7.0.11
'@auth/prisma-adapter@2.11.1(@prisma/client@6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3))(nodemailer@7.0.11)':
dependencies:
'@auth/core': 0.41.1(nodemailer@7.0.11)
'@prisma/client': 6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3)
transitivePeerDependencies:
- '@simplewebauthn/browser'
- '@simplewebauthn/server'
- nodemailer
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -19104,6 +19153,12 @@ snapshots:
preact: 10.26.6
pretty-format: 3.8.0
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
preact@10.26.6: {}
prebuild-install@7.1.3: