From a26343e99d380a0170565be2b91ed169a0a63c54 Mon Sep 17 00:00:00 2001 From: Opti1337 Date: Sat, 5 Sep 2026 14:46:17 +0600 Subject: [PATCH] session livekit token --- server/auth/lucia.ts | 17 +++- server/package.json | 1 + .../migration.sql | 2 + server/prisma/schema.prisma | 37 ++++----- server/routes/auth.ts | 26 +++++-- server/server.ts | 2 + server/utils/livekit.ts | 78 +++++++++++++++++++ server/yarn.lock | 35 +++++++++ 8 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 server/prisma/migrations/20260905083743_session_livekit_token/migration.sql create mode 100644 server/utils/livekit.ts diff --git a/server/auth/lucia.ts b/server/auth/lucia.ts index 39626aa..8059e6c 100644 --- a/server/auth/lucia.ts +++ b/server/auth/lucia.ts @@ -1,12 +1,13 @@ import { PrismaAdapter } from '@lucia-auth/adapter-prisma' -import { Lucia } from 'lucia' +import { Lucia, TimeSpan } from 'lucia' import prisma from '../prisma/client.ts' declare module 'lucia' { interface Register { - Lucia: typeof Lucia + Lucia: typeof auth UserId: string DatabaseUserAttributes: DatabaseUserAttributes + DatabaseSessionAttributes: DatabaseSessionAttributes } } @@ -16,7 +17,14 @@ interface DatabaseUserAttributes { username: string } +interface DatabaseSessionAttributes { + livekitToken: string | null +} + +export const SESSION_EXPIRES_IN = new TimeSpan(30, 'd') + export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), { + sessionExpiresIn: SESSION_EXPIRES_IN, sessionCookie: { attributes: { sameSite: 'none', @@ -29,6 +37,11 @@ export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), { username, } }, + getSessionAttributes: ({ livekitToken }) => { + return { + livekitToken, + } + }, }) export type Auth = typeof auth diff --git a/server/package.json b/server/package.json index a71c23c..ec82601 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "dotenv": "^17.2.3", "fastify": "^5.6.1", "fastify-plugin": "^5.1.0", + "livekit-server-sdk": "^2.18.0", "lucia": "^3.2.2", "mediasoup": "^3.19.3", "prisma": "^6.17.0", diff --git a/server/prisma/migrations/20260905083743_session_livekit_token/migration.sql b/server/prisma/migrations/20260905083743_session_livekit_token/migration.sql new file mode 100644 index 0000000..c7d6f05 --- /dev/null +++ b/server/prisma/migrations/20260905083743_session_livekit_token/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "livekitToken" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8477571..c2a65b7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -9,32 +9,33 @@ generator client { } model User { - id String @id @default(cuid()) - username String @unique - password String - displayName String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + username String @unique + password String + displayName String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - Session Session[] - UserPreferences UserPreferences? + Session Session[] + UserPreferences UserPreferences? } model Session { - id String @id - userId String - expiresAt DateTime + id String @id + userId String + expiresAt DateTime + livekitToken String? - user User @relation(references: [id], fields: [userId], onDelete: Cascade) + user User @relation(references: [id], fields: [userId], onDelete: Cascade) @@index([userId]) } model UserPreferences { - userId String @id - toggleInputHotkey String? @default("") - toggleOutputHotkey String? @default("") - volumes Json? @default("{}") + userId String @id + toggleInputHotkey String? @default("") + toggleOutputHotkey String? @default("") + volumes Json? @default("{}") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} \ No newline at end of file + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 8cfdf1a..a59c947 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -3,6 +3,7 @@ import bcrypt from 'bcrypt' import { z } from 'zod' import { auth } from '../auth/lucia.ts' import prisma from '../prisma/client.ts' +import { createSessionLivekitToken, getSessionLivekitToken } from '../utils/livekit.ts' export default function (fastify: FastifyInstance) { fastify.post('/register', async (req, reply) => { @@ -22,7 +23,9 @@ export default function (fastify: FastifyInstance) { }, }) - const session = await auth.createSession(user.id, {}) + const session = await auth.createSession(user.id, { + livekitToken: await createSessionLivekitToken(user), + }) const cookie = auth.createSessionCookie(session.id) reply.setCookie(cookie.name, cookie.value, cookie.attributes) @@ -67,7 +70,9 @@ export default function (fastify: FastifyInstance) { return reply.code(404).send({ error: 'Incorrect username or password' }) } - const session = await auth.createSession(user.id, {}) + const session = await auth.createSession(user.id, { + livekitToken: await createSessionLivekitToken(user), + }) const cookie = auth.createSessionCookie(session.id) reply.setCookie(cookie.name, cookie.value, cookie.attributes) @@ -92,11 +97,22 @@ export default function (fastify: FastifyInstance) { }) fastify.get('/me', async (req, reply) => { - if (req.user) { - return req.user + if (!req.user || !req.session) { + reply.code(401).send(false) + + return } - reply.code(401).send(false) + try { + return { + ...req.user, + livekitToken: await getSessionLivekitToken(req.user, req.session), + } + } + catch (err) { + fastify.log.error(err) + reply.code(500).send({ error: err.message }) + } }) fastify.post('/logout', async (req, reply) => { diff --git a/server/server.ts b/server/server.ts index 18a60a8..be3a180 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,3 +1,5 @@ +/* eslint-disable perfectionist/sort-imports -- .env must be loaded before any import that reads process.env */ +import 'dotenv/config' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import FastifyAutoLoad from '@fastify/autoload' diff --git a/server/utils/livekit.ts b/server/utils/livekit.ts new file mode 100644 index 0000000..8b34af0 --- /dev/null +++ b/server/utils/livekit.ts @@ -0,0 +1,78 @@ +import type { Session, User } from 'lucia' +import { Buffer } from 'node:buffer' +import { AccessToken } from 'livekit-server-sdk' +import { SESSION_EXPIRES_IN } from '../auth/lucia.ts' +import prisma from '../prisma/client.ts' + +/** + * Re-issue the token slightly before it actually expires, so a client that + * caches the /me response for a while still gets a usable token. + */ +const REFRESH_BEFORE_EXPIRY_SECONDS = 60 * 60 + +export async function createLivekitToken(user: User, ttlSeconds: number) { + const apiKey = process.env.LIVEKIT_API_KEY + const apiSecret = process.env.LIVEKIT_API_SECRET + + if (!apiKey || !apiSecret) + throw new Error('LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set') + + const token = new AccessToken(apiKey, apiSecret, { + identity: user.id, + name: user.displayName, + ttl: ttlSeconds, + }) + + token.addGrant({ + room: process.env.LIVEKIT_ROOM || 'chad', + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + canUpdateOwnMetadata: true, + }) + + return token.toJwt() +} + +/** Mints the token that gets stored on a session at the moment it is created. */ +export function createSessionLivekitToken(user: User) { + return createLivekitToken(user, SESSION_EXPIRES_IN.seconds()) +} + +/** + * Returns the token stored alongside the session, re-issuing and persisting it + * when it is missing (session predates the column) or about to expire (Lucia + * extends `expiresAt` on active sessions, outliving the original token). + */ +export async function getSessionLivekitToken(user: User, session: Session) { + if (session.livekitToken && !isExpiringSoon(session.livekitToken)) + return session.livekitToken + + const ttlSeconds = Math.max( + Math.ceil((session.expiresAt.getTime() - Date.now()) / 1000), + REFRESH_BEFORE_EXPIRY_SECONDS, + ) + const livekitToken = await createLivekitToken(user, ttlSeconds) + + await prisma.session.update({ + where: { id: session.id }, + data: { livekitToken }, + }) + + session.livekitToken = livekitToken + + return livekitToken +} + +function isExpiringSoon(token: string) { + try { + const [, payload] = token.split('.') + const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString()) + + return typeof exp !== 'number' || exp - REFRESH_BEFORE_EXPIRY_SECONDS <= Date.now() / 1000 + } + catch { + return true + } +} diff --git a/server/yarn.lock b/server/yarn.lock index 8016aa9..cdf9f7c 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -119,6 +119,13 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^1.10.0, @bufbuild/protobuf@npm:^1.10.1": + version: 1.10.1 + resolution: "@bufbuild/protobuf@npm:1.10.1" + checksum: 10c0/a89572ae99aa193dd232fca0cdc9ece1dfe2f3d8b061be1f966a4f88fb63410aeb0fe7de927037e970aefcb52036eec58a7f89a40fe1286eed1448ea1bd2634e + languageName: node + linkType: hard + "@clack/core@npm:0.5.0": version: 0.5.0 resolution: "@clack/core@npm:0.5.0" @@ -498,6 +505,15 @@ __metadata: languageName: node linkType: hard +"@livekit/protocol@npm:1.48.0": + version: 1.48.0 + resolution: "@livekit/protocol@npm:1.48.0" + dependencies: + "@bufbuild/protobuf": "npm:^1.10.0" + checksum: 10c0/665ada6b38578f53bc2854d709f05a642cfdad8c671571bd02dc0c6d1b94028d02ce5e7dd86862a2260d6fc0647d333811ce3b85127ba40e37ef76d426547d48 + languageName: node + linkType: hard + "@lucia-auth/adapter-prisma@npm:^4.0.1": version: 4.0.1 resolution: "@lucia-auth/adapter-prisma@npm:4.0.1" @@ -2747,6 +2763,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.1.2": + version: 5.10.0 + resolution: "jose@npm:5.10.0" + checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0 + languageName: node + linkType: hard + "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -2876,6 +2899,17 @@ __metadata: languageName: node linkType: hard +"livekit-server-sdk@npm:^2.18.0": + version: 2.18.0 + resolution: "livekit-server-sdk@npm:2.18.0" + dependencies: + "@bufbuild/protobuf": "npm:^1.10.1" + "@livekit/protocol": "npm:1.48.0" + jose: "npm:^5.1.2" + checksum: 10c0/5150170be1302bbbcde32f558eb6104a93a6a56fc656b776db50db7eb0a35285e54c28630d993ac809ecb025626d4c7c989721bebc0d0562640b58edcdd72abe + languageName: node + linkType: hard + "local-pkg@npm:^1.1.2": version: 1.1.2 resolution: "local-pkg@npm:1.1.2" @@ -4303,6 +4337,7 @@ __metadata: eslint: "npm:^9.36.0" fastify: "npm:^5.6.1" fastify-plugin: "npm:^5.1.0" + livekit-server-sdk: "npm:^2.18.0" lucia: "npm:^3.2.2" mediasoup: "npm:^3.19.3" prisma: "npm:^6.17.0"