From 41e1baf480da0bd449b5e4af97d3b49edd062977 Mon Sep 17 00:00:00 2001 From: NdekoCode Date: Sun, 16 Aug 2026 20:23:56 +0200 Subject: [PATCH 1/5] fix(web): require auth on LiveKit token endpoint and scope rooms per tenant GET /api/livekit had no session check, and no middleware covers apps/web. It read roomName and username from the query string and returned a token granting roomAdmin, roomCreate, roomRecord, recorder, roomList and agent, so anyone could mint an admin token for any room under any identity. Put the handler behind authenticatedGuard, derive the identity from the Gauzy session instead of the query string, and drop the six grants the meet UI never used. Room names stay client-provided since they are ad-hoc and shared by link, but are now prefixed with the caller's tenant so a leaked link cannot reach another organization. Also stop caching the token in localStorage: it lived under a single key shared by every room, so switching rooms briefly published local tracks into the room the user had just left. --- apps/web/app/api/livekit/route.ts | 88 ++++++++----------- .../pages/meet/livekit/page-component.tsx | 3 +- apps/web/core/hooks/common/use-live-kit.ts | 27 +++--- apps/web/core/services/server/livekitroom.ts | 6 +- 4 files changed, 56 insertions(+), 68 deletions(-) diff --git a/apps/web/app/api/livekit/route.ts b/apps/web/app/api/livekit/route.ts index 8e07b29b99..7a964ca846 100644 --- a/apps/web/app/api/livekit/route.ts +++ b/apps/web/app/api/livekit/route.ts @@ -1,59 +1,45 @@ -import { AccessToken } from "livekit-server-sdk"; -import { NextRequest, NextResponse } from "next/server"; +import { authenticatedGuard } from '@/core/services/server/guards/authenticated-guard-app'; +import { AccessToken } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { - const room = req.nextUrl.searchParams.get("roomName"); - const username = req.nextUrl.searchParams.get("username"); + const res = new NextResponse(); + const { user } = await authenticatedGuard(req, res); - if (!room || typeof room !== 'string' || room.trim() === '') { - return NextResponse.json( - { error: 'Missing or invalid "roomName" query parameter' }, - { status: 400 } - ); - } + if (!user || !user.tenantId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } - if (!username || typeof username !== 'string' || username.trim() === '') { - return NextResponse.json( - { error: 'Missing or invalid "username" query parameter' }, - { status: 400 } - ); - } + const room = req.nextUrl.searchParams.get('roomName'); - const apiKey = process.env.LIVEKIT_API_KEY; - const apiSecret = process.env.LIVEKIT_API_SECRET; - const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL; + if (!room || room.trim() === '') { + return NextResponse.json({ error: 'Missing or invalid "roomName" query parameter' }, { status: 400 }); + } - if (!apiKey || !apiSecret || !wsUrl) { - console.error("Server misconfigured: missing environment variables."); - return NextResponse.json( - { error: "Server misconfigured" }, - { status: 500 } - ); - } + const apiKey = process.env.LIVEKIT_API_KEY; + const apiSecret = process.env.LIVEKIT_API_SECRET; + const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL; - try { - const at = new AccessToken(apiKey, apiSecret, { identity: username, ttl: '1h' }); - at.addGrant({ - room, - roomJoin: true, - canPublish: true, - canSubscribe: true, - roomRecord: true, - roomCreate: true, - roomAdmin: true, - recorder: true, - roomList: true, - canUpdateOwnMetadata: true, - agent: true, - canPublishData: true, - }); - const token = await at.toJwt(); - return NextResponse.json({ token: token }); - } catch (error) { - console.error("Failed to generate token:", error); - return NextResponse.json( - { error: "Failed to generate token" }, - { status: 500 } - ); - } + if (!apiKey || !apiSecret || !wsUrl) { + console.error('Server misconfigured: missing environment variables.'); + return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 }); + } + + try { + const at = new AccessToken(apiKey, apiSecret, { identity: user.email || user.id, ttl: '1h' }); + at.addGrant({ + // Rooms are shared by link, so scoping per tenant keeps a leaked link within its tenant + room: `${user.tenantId}:${room}`, + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + canUpdateOwnMetadata: true + }); + const token = await at.toJwt(); + return NextResponse.json({ token: token }); + } catch (error) { + console.error('Failed to generate token:', error); + return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 }); + } } diff --git a/apps/web/core/components/pages/meet/livekit/page-component.tsx b/apps/web/core/components/pages/meet/livekit/page-component.tsx index 6c700958c8..0f8e4e7aa3 100644 --- a/apps/web/core/components/pages/meet/livekit/page-component.tsx +++ b/apps/web/core/components/pages/meet/livekit/page-component.tsx @@ -30,8 +30,7 @@ function LiveKitPage() { }, [params]); const { token } = useTokenLiveKit({ - roomName: roomName || '', - username: user?.email || '' + roomName: roomName || '' }); return ( diff --git a/apps/web/core/hooks/common/use-live-kit.ts b/apps/web/core/hooks/common/use-live-kit.ts index eb0049b346..119d779e33 100644 --- a/apps/web/core/hooks/common/use-live-kit.ts +++ b/apps/web/core/hooks/common/use-live-kit.ts @@ -4,29 +4,34 @@ import { useEffect, useState } from 'react'; interface ITokenLiveKitProps { roomName: string; - username: string; } -export function useTokenLiveKit({ roomName, username }: ITokenLiveKitProps) { - const [token, setToken] = useState(() => { - if (typeof window !== 'undefined') { - return window.localStorage.getItem('token-live-kit'); - } - return null; - }); +export function useTokenLiveKit({ roomName }: ITokenLiveKitProps) { + const [token, setToken] = useState(null); useEffect(() => { + // A stale token would publish local tracks into the room the user just left + setToken(null); + + if (!roomName) return; + + let cancelled = false; + const fetchToken = async () => { try { - const response = await tokenLiveKitRoom({ roomName, username }); - window.localStorage.setItem('token-live-kit', response.token); + const response = await tokenLiveKitRoom({ roomName }); + if (cancelled || !response?.token) return; setToken(response.token); } catch (error) { console.error('Failed to fetch token:', error); } }; fetchToken(); - }, [roomName, username]); + + return () => { + cancelled = true; + }; + }, [roomName]); return { token }; } diff --git a/apps/web/core/services/server/livekitroom.ts b/apps/web/core/services/server/livekitroom.ts index e388232f55..e0a42132e3 100644 --- a/apps/web/core/services/server/livekitroom.ts +++ b/apps/web/core/services/server/livekitroom.ts @@ -1,10 +1,8 @@ import { ILiveKitCredentials } from '@/core/types/interfaces/integrations/livekit-credentials'; -export async function tokenLiveKitRoom({ roomName, username }: ILiveKitCredentials) { +export async function tokenLiveKitRoom({ roomName }: ILiveKitCredentials) { try { - const response = await fetch( - `/api/livekit?roomName=${roomName ?? 'default'}&username=${username ?? 'employee'}` - ); + const response = await fetch(`/api/livekit?roomName=${roomName ?? 'default'}`); return await response.json(); } catch (e) { console.error(e); From 882427c3078b421c5fe771f32455e5249ec1df6a Mon Sep 17 00:00:00 2001 From: NdekoCode Date: Sun, 16 Aug 2026 20:30:10 +0200 Subject: [PATCH 2/5] fix: add /graphify-out to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4d0f7d18d1..fb8a798768 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,8 @@ megalinter-reports/ /.deploy/redis/jitsu_users_recognition/data/*.rdb /.deploy/jitsu/server/data/logs/events +/graphify-out + .cursor\rules\nx-rules.mdc .github\instructions\nx.instructions.md From ac329eeff6141c04f3749e28485fd5c08f4efdfd Mon Sep 17 00:00:00 2001 From: NdekoCode Date: Sun, 16 Aug 2026 20:33:55 +0200 Subject: [PATCH 3/5] fix(web): encode and normalize the LiveKit room name Review feedback. roomName was interpolated raw into the query string, so a generated base64 name containing "+" reached the route decoded as a space. Build the query with URLSearchParams instead. Trim the room name once when reading it and use that value in the grant, so "abc" and " abc" can no longer resolve to two different rooms. --- apps/web/app/api/livekit/route.ts | 4 ++-- apps/web/core/services/server/livekitroom.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/livekit/route.ts b/apps/web/app/api/livekit/route.ts index 7a964ca846..b36c7f9014 100644 --- a/apps/web/app/api/livekit/route.ts +++ b/apps/web/app/api/livekit/route.ts @@ -10,9 +10,9 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const room = req.nextUrl.searchParams.get('roomName'); + const room = req.nextUrl.searchParams.get('roomName')?.trim(); - if (!room || room.trim() === '') { + if (!room) { return NextResponse.json({ error: 'Missing or invalid "roomName" query parameter' }, { status: 400 }); } diff --git a/apps/web/core/services/server/livekitroom.ts b/apps/web/core/services/server/livekitroom.ts index e0a42132e3..f65f1eb87d 100644 --- a/apps/web/core/services/server/livekitroom.ts +++ b/apps/web/core/services/server/livekitroom.ts @@ -2,7 +2,8 @@ import { ILiveKitCredentials } from '@/core/types/interfaces/integrations/liveki export async function tokenLiveKitRoom({ roomName }: ILiveKitCredentials) { try { - const response = await fetch(`/api/livekit?roomName=${roomName ?? 'default'}`); + const query = new URLSearchParams({ roomName: roomName ?? 'default' }); + const response = await fetch(`/api/livekit?${query.toString()}`); return await response.json(); } catch (e) { console.error(e); From 48cb029e1a09577a72288a04782417c3657268eb Mon Sep 17 00:00:00 2001 From: NdekoCode Date: Sun, 16 Aug 2026 20:36:48 +0200 Subject: [PATCH 4/5] fix(web): bind the LiveKit token to its room and drop canUpdateOwnMetadata Review feedback. Clearing the token from a passive effect left one render where the new room was already selected but the previous token was still handed out. Store the room the token was issued for and return it only when it matches the current room, so the swap happens during render instead of one tick later. This also removes the need for the cancellation flag. Neither our code nor @livekit/components-react ever mutates participant metadata, so canUpdateOwnMetadata is dropped from the grant. --- apps/web/app/api/livekit/route.ts | 3 +-- apps/web/core/hooks/common/use-live-kit.ts | 19 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/web/app/api/livekit/route.ts b/apps/web/app/api/livekit/route.ts index b36c7f9014..db03276983 100644 --- a/apps/web/app/api/livekit/route.ts +++ b/apps/web/app/api/livekit/route.ts @@ -33,8 +33,7 @@ export async function GET(req: NextRequest) { roomJoin: true, canPublish: true, canSubscribe: true, - canPublishData: true, - canUpdateOwnMetadata: true + canPublishData: true }); const token = await at.toJwt(); return NextResponse.json({ token: token }); diff --git a/apps/web/core/hooks/common/use-live-kit.ts b/apps/web/core/hooks/common/use-live-kit.ts index 119d779e33..faf2e129c3 100644 --- a/apps/web/core/hooks/common/use-live-kit.ts +++ b/apps/web/core/hooks/common/use-live-kit.ts @@ -7,31 +7,24 @@ interface ITokenLiveKitProps { } export function useTokenLiveKit({ roomName }: ITokenLiveKitProps) { - const [token, setToken] = useState(null); + const [issued, setIssued] = useState<{ room: string; token: string } | null>(null); useEffect(() => { - // A stale token would publish local tracks into the room the user just left - setToken(null); - if (!roomName) return; - let cancelled = false; - const fetchToken = async () => { try { const response = await tokenLiveKitRoom({ roomName }); - if (cancelled || !response?.token) return; - setToken(response.token); + if (!response?.token) return; + setIssued({ room: roomName, token: response.token }); } catch (error) { console.error('Failed to fetch token:', error); } }; fetchToken(); - - return () => { - cancelled = true; - }; }, [roomName]); - return { token }; + // A token only grants the room it was issued for, so handing back one from a previous + // room would publish local tracks into the room the user just left + return { token: issued?.room === roomName ? issued.token : null }; } From c5dd2424cb7b9a41c1e08749874df5a9a4856890 Mon Sep 17 00:00:00 2001 From: NdekoCode Date: Sun, 16 Aug 2026 20:39:46 +0200 Subject: [PATCH 5/5] docs(web): note why the LiveKit route uses the session tenant The guard also exposes a tenantId read from the auth-tenant-id cookie, which is not httpOnly and can be rewritten from the browser. Scoping the room grant with it would let a caller pick another tenant's rooms, so the route uses the tenant returned by /user/me instead. Two reviewers flagged the unused guard value, hence the note. --- apps/web/app/api/livekit/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/api/livekit/route.ts b/apps/web/app/api/livekit/route.ts index db03276983..1d535871a4 100644 --- a/apps/web/app/api/livekit/route.ts +++ b/apps/web/app/api/livekit/route.ts @@ -6,6 +6,7 @@ export async function GET(req: NextRequest) { const res = new NextResponse(); const { user } = await authenticatedGuard(req, res); + // Session tenant, not the guard's auth-tenant-id cookie: that one is client-writable if (!user || !user.tenantId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }