feature(auth): replace clerk.com with custom auth (#103)

* feature(auth): replace clerk.com with custom auth

* minor fixes

* remove notification preferences

* decrease live events interval

fix(api): cookies..

# Conflicts:
#	.gitignore
#	apps/api/src/index.ts
#	apps/dashboard/src/app/providers.tsx
#	packages/trpc/src/trpc.ts
This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-18 21:30:39 +01:00
committed by Carl-Gerhard Lindesvärd
parent f28802b1c2
commit d31d9924a5
151 changed files with 18484 additions and 12853 deletions

View File

@@ -11,3 +11,15 @@ export const TRPCNotFoundError = (message: string) =>
code: 'NOT_FOUND',
message,
});
export const TRPCInternalServerError = (message: string) =>
new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message,
});
export const TRPCBadRequestError = (message: string) =>
new TRPCError({
code: 'BAD_REQUEST',
message,
});

View File

@@ -1,3 +1,4 @@
import { authRouter } from './routers/auth';
import { chartRouter } from './routers/chart';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
ticket: ticketRouter,
notification: notificationRouter,
integration: integrationRouter,
auth: authRouter,
});
// export type definition of API

View File

@@ -0,0 +1,279 @@
import {
Arctic,
createSession,
deleteSessionTokenCookie,
generateSessionToken,
github,
google,
hashPassword,
invalidateSession,
setSessionTokenCookie,
verifyPasswordHash,
} from '@openpanel/auth';
import { generateSecureId } from '@openpanel/common/server/id';
import { connectUserToOrganization, db, getUserAccount } from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
import {
zRequestResetPassword,
zResetPassword,
zSignInEmail,
zSignUpEmail,
} from '@openpanel/validation';
import * as bcrypt from 'bcrypt';
import { z } from 'zod';
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
import {
createTRPCRouter,
publicProcedure,
rateLimitMiddleware,
} from '../trpc';
const zProvider = z.enum(['email', 'google', 'github']);
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(({ input, ctx }) => {
const { provider } = input;
if (provider === 'github') {
const state = Arctic.generateState();
const url = github.createAuthorizationURL(state, [
'user:email',
'user:read',
]);
// if we have an inviteId we want to add it to the redirect url
// so we have this information in the callback url later
if (input.inviteId) {
const redirectUri = url.searchParams.get('redirect_uri');
if (redirectUri) {
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set('inviteId', input.inviteId);
url.searchParams.set('redirect_uri', redirectUrl.toString());
}
}
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 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 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 ?? '',
input.password,
);
if (!validPassword) {
throw TRPCAccessError('Incorrect email or password');
}
} else {
const validPassword = await bcrypt.compare(
input.password,
user.account.password ?? '',
);
if (!validPassword) {
throw TRPCAccessError('Incorrect email or password');
}
}
}
const token = generateSessionToken();
const session = await createSession(token, user.id);
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
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.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`,
},
});
return true;
}),
session: publicProcedure.query(async ({ ctx }) => {
return ctx.session;
}),
});

View File

@@ -1,12 +1,13 @@
import { clerkClient } from '@clerk/fastify';
import { pathOr } from 'ramda';
import { z } from 'zod';
import { db } from '@openpanel/db';
import { connectUserToOrganization, db } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
import { generateSecureId } from '@openpanel/common/server/id';
import { sendEmail } from '@openpanel/email';
import { addDays } from 'date-fns';
import { getOrganizationAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const organizationRouter = createTRPCRouter({
@@ -59,68 +60,101 @@ export const organizationRouter = createTRPCRouter({
},
});
let invitationId: string | undefined;
const alreadyMember = await db.member.findFirst({
where: {
userId: userExists?.id,
organizationId: input.organizationId,
},
});
if (!userExists) {
const ticket = await clerkClient.invitations.createInvitation({
emailAddress: email,
notify: true,
});
invitationId = ticket.id;
if (alreadyMember && userExists) {
throw TRPCBadRequestError(
'User is already a member of the organization',
);
}
return db.member.create({
const alreadyInvited = await db.invite.findFirst({
where: {
email,
organizationId: input.organizationId,
},
});
if (alreadyInvited) {
throw TRPCBadRequestError(
'User is already invited to the organization',
);
}
const invite = await db.invite.create({
data: {
id: generateSecureId('invite'),
email,
organizationId: input.organizationId,
role: input.role,
invitedById: ctx.session.userId,
meta: {
access: input.access,
invitationId,
createdById: ctx.session.userId,
projectAccess: input.access || [],
expiresAt: addDays(new Date(), 3),
},
include: {
organization: {
select: {
name: true,
},
},
},
});
if (userExists) {
const member = await connectUserToOrganization({
user: userExists,
inviteId: invite.id,
});
return {
type: 'is_member',
member,
};
}
await sendEmail('invite', {
to: email,
data: {
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/onboarding?inviteId=${invite.id}`,
organizationName: invite.organization.name,
},
});
return {
type: 'is_invited',
invite,
};
}),
revokeInvite: protectedProcedure
.input(
z.object({
memberId: z.string(),
inviteId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const member = await db.member.findUniqueOrThrow({
const invite = await db.invite.findUniqueOrThrow({
where: {
id: input.memberId,
id: input.inviteId,
},
});
const access = await getOrganizationAccess({
userId: ctx.session.userId,
organizationId: member.organizationId,
organizationId: invite.organizationId,
});
if (access?.role !== 'org:admin') {
throw TRPCAccessError('You do not have access to this project');
}
const invitationId = pathOr<string | undefined>(
undefined,
['meta', 'invitationId'],
member,
);
if (invitationId) {
await clerkClient.invitations
.revokeInvitation(invitationId)
.catch(() => {
// Ignore errors, this will throw if the invitation is already accepted
});
}
return db.member.delete({
return db.invite.delete({
where: {
id: input.memberId,
id: input.inviteId,
},
});
}),

View File

@@ -1,9 +1,8 @@
import { clerkClient } from '@clerk/fastify';
import { z } from 'zod';
import { db } from '@openpanel/db';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
update: protectedProcedure
@@ -14,23 +13,15 @@ export const userRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const [updatedUser] = await Promise.all([
db.user.update({
where: {
id: ctx.session.userId,
},
data: {
firstName: input.firstName,
lastName: input.lastName,
},
}),
clerkClient.users.updateUser(ctx.session.userId, {
return db.user.update({
where: {
id: ctx.session.userId,
},
data: {
firstName: input.firstName,
lastName: input.lastName,
}),
]);
return updatedUser;
},
});
}),
debugPostCookie: protectedProcedure
.input(

View File

@@ -1,24 +1,54 @@
import { getAuth } from '@clerk/fastify';
import { TRPCError, initTRPC } from '@trpc/server';
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
import { has } from 'ramda';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { COOKIE_OPTIONS, validateSessionToken } from '@openpanel/auth';
import { getRedisCache } from '@openpanel/redis';
import type { ISetCookie } from '@openpanel/validation';
import {
createTrpcRedisLimiter,
defaultFingerPrint,
} from '@trpc-limiter/redis';
import { getOrganizationAccessCached, getProjectAccessCached } from './access';
import { TRPCAccessError } from './errors';
export function createContext({ req, res }: CreateFastifyContextOptions) {
export const rateLimitMiddleware = ({
max,
windowMs,
}: {
max: number;
windowMs: number;
}) =>
createTrpcRedisLimiter<typeof t>({
fingerprint: (ctx) => defaultFingerPrint(ctx.req),
message: (hitInfo) =>
`Too many requests, please try again later. ${hitInfo}`,
max,
windowMs,
redisClient: getRedisCache(),
});
export async function createContext({ req, res }: CreateFastifyContextOptions) {
const setCookie: ISetCookie = (key, value, options) => {
// @ts-ignore
res.setCookie(key, value, {
maxAge: options.maxAge,
...COOKIE_OPTIONS,
});
};
// @ts-ignore
const session = await validateSessionToken(req.cookies?.session);
return {
req,
res,
session: getAuth(req),
session,
// we do not get types for `setCookie` from fastify
// so define it here and be safe in routers
setCookie: (key: string, value: string, options: any) => {
// @ts-ignore
res.setCookie(key, value, options);
},
setCookie,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;