import { Arctic, COOKIE_OPTIONS, createSession, deleteSessionTokenCookie, generateSessionToken, github, google, hashPassword, invalidateSession, setSessionTokenCookie, validateSessionToken, verifyPasswordHash, } from '@openpanel/auth'; import { generateSecureId } from '@openpanel/common/server/id'; import { connectUserToOrganization, db, getShareOverviewById, getUserAccount, } from '@openpanel/db'; import { sendEmail } from '@openpanel/email'; import { zRequestResetPassword, zResetPassword, zSignInEmail, zSignInShare, zSignUpEmail, } from '@openpanel/validation'; import { z } from 'zod'; import { TRPCAccessError, TRPCNotFoundError } from '../errors'; import { createTRPCRouter, publicProcedure, rateLimitMiddleware, } from '../trpc'; const zProvider = z.enum(['email', 'google', 'github']); async function getIsRegistrationAllowed(inviteId?: string | null) { // ALLOW_REGISTRATION is always undefined in cloud if (process.env.ALLOW_REGISTRATION === undefined) { return true; } // Self-hosting logic // 1. First user is always allowed const count = await db.user.count(); if (count === 0) { return true; } // 2. If there is an invite, check if it is valid if (inviteId) { if (process.env.ALLOW_INVITATION === 'false') { return false; } const invite = await db.invite.findUnique({ where: { id: inviteId, }, }); return !!invite; } // 3. Otherwise, check if general registration is allowed return process.env.ALLOW_REGISTRATION !== 'false'; } export const authRouter = createTRPCRouter({ signOut: publicProcedure.mutation(async ({ ctx }) => { deleteSessionTokenCookie(ctx.setCookie); if (ctx.session?.session?.id) { await invalidateSession(ctx.session.session.id); } }), signInOAuth: publicProcedure .input(z.object({ provider: zProvider, inviteId: z.string().nullish() })) .mutation(async ({ input, ctx }) => { const isRegistrationAllowed = await getIsRegistrationAllowed( input.inviteId, ); if (!isRegistrationAllowed) { throw TRPCAccessError('Registrations are not allowed'); } const { provider } = input; if (input.inviteId) { ctx.setCookie('inviteId', input.inviteId, { maxAge: 60 * 10, }); } if (provider === 'github') { const state = Arctic.generateState(); const url = github.createAuthorizationURL(state, [ 'user:email', 'user:read', ]); ctx.setCookie('github_oauth_state', state, { maxAge: 60 * 10, }); return { type: 'github', url: url.toString(), }; } const state = Arctic.generateState(); const codeVerifier = Arctic.generateCodeVerifier(); const url = google.createAuthorizationURL(state, codeVerifier, [ 'openid', 'profile', 'email', ]); ctx.setCookie('google_oauth_state', state, { maxAge: 60 * 10, }); ctx.setCookie('google_code_verifier', codeVerifier, { maxAge: 60 * 10, }); return { type: 'google', url: url.toString(), }; }), signUpEmail: publicProcedure .input(zSignUpEmail) .mutation(async ({ input, ctx }) => { const isRegistrationAllowed = await getIsRegistrationAllowed( input.inviteId, ); if (!isRegistrationAllowed) { throw TRPCAccessError('Registrations are not allowed'); } const provider = 'email'; const user = await getUserAccount({ email: input.email, provider, }); if (user) { throw TRPCNotFoundError('User already exists'); } const createdUser = await db.user.create({ data: { id: generateSecureId('user'), email: input.email, firstName: input.firstName, lastName: input.lastName, accounts: { create: { provider, password: await hashPassword(input.password), }, }, }, }); if (input.inviteId) { await connectUserToOrganization({ user: createdUser, inviteId: input.inviteId, }); } const token = generateSessionToken(); const session = await createSession(token, createdUser.id); setSessionTokenCookie(ctx.setCookie, token, session.expiresAt); return session; }), signInEmail: publicProcedure .use( rateLimitMiddleware({ max: 3, windowMs: 30_000, }), ) .input(zSignInEmail) .mutation(async ({ input, ctx }) => { const provider = 'email'; const password = input.password.trim(); const user = await getUserAccount({ email: input.email, provider, }); if (!user) { throw TRPCNotFoundError('User does not exists'); } if (provider === 'email') { // if the password starts with $argon2 we use the new password hashing // otherwise its legacy from Clerk which uses bcrypt // TODO: Remove this after 2025-06-01 (half year from now) if (user.account.password?.startsWith('$argon2')) { const validPassword = await verifyPasswordHash( user.account.password ?? '', password, ); if (!validPassword) { throw TRPCAccessError('Incorrect email or password'); } } else { throw TRPCAccessError( 'Reset your password, old password has expired', ); } } const token = generateSessionToken(); const session = await createSession(token, user.id); console.log('session', session); setSessionTokenCookie(ctx.setCookie, token, session.expiresAt); console.log('ctx.setCookie', ctx.setCookie); return { type: 'email', }; }), resetPassword: publicProcedure .input(zResetPassword) .use( rateLimitMiddleware({ max: 3, windowMs: 60_000, }), ) .mutation(async ({ input, ctx }) => { const { token, password } = input; const resetPassword = await db.resetPassword.findUnique({ where: { id: token, }, }); if (!resetPassword) { throw TRPCNotFoundError('Reset password not found'); } if (resetPassword.expiresAt < new Date()) { throw TRPCNotFoundError('Reset password expired'); } await db.account.update({ where: { id: resetPassword.accountId }, data: { password: await hashPassword(password), }, }); await db.resetPassword.delete({ where: { id: token }, }); return true; }), requestResetPassword: publicProcedure .use( rateLimitMiddleware({ max: 3, windowMs: 60_000, }), ) .input(zRequestResetPassword) .mutation(async ({ input, ctx }) => { const user = await getUserAccount({ email: input.email, provider: 'email', }); if (!user) { return true; } if (!user.account.id) { return true; } await db.resetPassword.deleteMany({ where: { accountId: user.account.id, }, }); const token = generateSecureId('pw'); // expires in 10 minutes const expiresAt = new Date(Date.now() + 1000 * 60 * 10); await db.resetPassword.create({ data: { id: token, expiresAt, accountId: user.account.id, }, }); await sendEmail('reset-password', { to: input.email, data: { url: `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`, }, }); return true; }), session: publicProcedure.query(async ({ ctx }) => { return ctx.session; }), extendSession: publicProcedure.mutation(async ({ ctx }) => { if (!ctx.session.session || !ctx.cookies.session) { return { extended: false }; } const token = ctx.cookies.session; const session = await validateSessionToken(token); if (session.session) { // Re-set the cookie with updated expiration setSessionTokenCookie(ctx.setCookie, token, session.session.expiresAt); return { extended: true, expiresAt: session.session.expiresAt, }; } return { extended: false }; }), signInShare: publicProcedure .use( rateLimitMiddleware({ max: 3, windowMs: 30_000, }), ) .input(zSignInShare) .mutation(async ({ input, ctx }) => { const { password, shareId } = input; const share = await getShareOverviewById(input.shareId); if (!share) { throw TRPCNotFoundError('Share not found'); } if (!share.public) { throw TRPCNotFoundError('Share is not public'); } if (!share.password) { throw TRPCNotFoundError('Share is not password protected'); } const validPassword = await verifyPasswordHash(share.password, password); if (!validPassword) { throw TRPCAccessError('Incorrect password'); } ctx.setCookie(`shared-overview-${shareId}`, '1', { maxAge: 60 * 60 * 24 * 7, ...COOKIE_OPTIONS, }); return true; }), });