mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
1 Commits
feat/custo
...
fix/logout
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c5ad2cdb8 |
87
apps/web/modules/auth/actions/invalidate-sessions.ts
Normal file
87
apps/web/modules/auth/actions/invalidate-sessions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
10
apps/web/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user?: {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
55
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user